Build on AtlasClicks
Gate your own software with AtlasClicks purchases. Three tools, all from My Store → Apps → API & webhooks: API keys for your server to ask questions, webhooks so you hear the moment a customer’s access changes, and Sign in with AtlasClicks so people log in to your app with the account they bought with. Nothing here can refund, cancel or move money. And the other way round: Atlas connectors let Atlas, the assistant in the app, call your API for your buyers — how to describe it.
API keys
Create a key in My Store → Apps → API & webhooks. It is shown once; AtlasClicks keeps only a hash. Send it as a bearer token from your server — never from a browser or an app you ship.
Authorization: Bearer ak_live_…
What a key can do
| Scope | Lets the key |
|---|---|
access:check | Ask whether a person has an active purchase — GET /api/v1/access |
customers:read | List your customers and what they own — GET /api/v1/customers |
products:read | List your products (pay links) — GET /api/v1/products |
checkout:create | Create a pay link — POST /api/v1/checkout-links |
webhooks:receive | Read recent webhook events to catch up on any you missed — GET /api/v1/events |
Keys can expire (30, 90 or 365 days) and can be revoked at any time. 300 requests a minute per key. A call with a missing scope answers 403 insufficient_scope; a revoked, expired or unknown key answers 401.
Check who has access
The one call most apps need. Match by the email the person paid with, or by their AtlasClicks user id (the sub from Sign in with AtlasClicks). Product ids are your pay-link ids — the panel lists them, so does /api/v1/products.
GET https://atlasclicks.com/api/v1/access?email=buyer@example.com&product=ck_vip
Authorization: Bearer ak_live_…
{
"has_access": true,
"status": "active", // active · canceled · none
"product": "ck_vip",
"product_name": "Bluebird VIP",
"kind": "subscription", // subscription · one_time
"since": "2026-09-12T10:00:00Z",
"cancel_requested": false, // they asked to cancel; access runs to period end
"customer": "buyer@example.com",
"purchases": [ { "id": "…", "product": "ck_vip", "status": "active", … } ]
}
Leave product out to ask “do they own anything from me?”. status is canceled when they bought before but no longer have access, and none when AtlasClicks has never seen that email or user for your store. A purchase grants access while its subscription is running, or for good after a one-time payment.
Reference
Everything is JSON over HTTPS at https://atlasclicks.com/api/v1. Every amount the API answers with is in cents. One request takes dollars — creating a pay link, below — because that is how prices are typed everywhere else in AtlasClicks.
| Call | Scope | Answers |
|---|---|---|
GET /access?email=|user=&product= | access:check | The access shape above. |
GET /products | products:read | { data: [ { id, name, amount, currency, interval, active, url } ] } — interval is month, year or null (one-time). |
GET /customers?limit=50&offset=0&product= | customers:read | { data: [ { email, name, user, has_access, since, purchases: [...] } ], total, has_more }, newest first. |
POST /checkout-links { name, amount, interval } | checkout:create | Send amount in dollars ($1–$50,000) and interval month / year / omitted. Answers 201 { id, name, amount, currency, interval, active, url } — the product as /products lists it, so amount comes back in cents (send 49, get 4900). |
GET /events?limit=30 | webhooks:receive | The most recent webhook events as data (same body as the deliveries), so a server that was down can catch up. |
GET /me/access?product= | user token | The access shape for the signed-in person — see Sign in with AtlasClicks. |
Errors come as { "error": "code", "message": "…" } with a matching HTTP status: 400 bad input, 401 bad key or token, 403 missing scope, 404 not found, 429 slow down.
Webhooks
Save one HTTPS endpoint per store. AtlasClicks POSTs to it when a customer’s access changes, signs every delivery with your signing secret, and retries up to six times over about fifteen minutes when your server does not answer 2xx. Use Send a test event in the panel to see the shape land on your server.
Events
| Event | When |
|---|---|
purchase.created | Someone paid — a new subscription or a one-time purchase. Grant access. |
purchase.renewed | A subscription renewed and the payment went through. Carries payment. |
payment.failed | A renewal charge failed. Stripe retries; access is still on. Carries payment.attempt and payment.next_attempt. |
purchase.cancelled | The subscription ended (cancelled or unpaid past the retries). Remove access. |
purchase.refunded | A charge was refunded, in full or in part. Carries refund.amount and refund.full. |
test | Sent from the panel. |
Body
POST https://yourapp.com/webhooks/atlasclicks
Content-Type: application/json
X-Atlas-Event: purchase.created
X-Atlas-Delivery: 7f3c… // unique per delivery — use it to ignore duplicates
X-Atlas-Signature: t=1789776000,v1=5a1c…
{
"id": "7f3c…", "event": "purchase.created", "created": "2026-09-19T05:31:00Z",
"business": { "slug": "bluebird-studio", "name": "Bluebird Studio" },
"data": {
"customer": { "email": "buyer@example.com", "name": "Jordan Lee", "user": "uid or null" },
"product": { "id": "ck_vip", "name": "Bluebird VIP" },
"purchase": { "id": "…", "status": "active", "kind": "subscription", "amount": 20000, "currency": "usd",
"since": "2026-09-19T05:31:00Z", "cancel_requested": false, "subscription": "sub_…" }
}
}
A purchase paid from an Atlas connector’s quote also carries "quote": { "ref": "Q-1042", "connector": "Acme Framing" } next to product — the ref your connector sent with the quote (Quotes and pictures). When the quote asked for them, customer.phone and a shipping block — the name and delivery address the buyer gave — come too.
Verify the signature
Compute HMAC-SHA256 over t + "." + raw body with your signing secret and compare it to v1 in constant time; reject anything older than five minutes. Read the raw request body — a re-serialised JSON object will not match.
// Node (Express: app.post('/webhooks/atlasclicks', express.raw({ type: '*/*' }), handler))
import crypto from 'node:crypto';
function verify(secret, header, rawBody) {
if (typeof header !== 'string' || !header) return false; // no header → not ours
const p = Object.fromEntries(header.split(',').map((kv) => kv.trim().split('=')));
const t = Number(p.t);
if (!p.v1 || !Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto.createHmac('sha256', secret).update(p.t + '.' + rawBody).digest('hex');
return expected.length === p.v1.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(p.v1));
}
// then: if (!verify(process.env.ATLAS_WEBHOOK_SECRET, req.get('X-Atlas-Signature'), req.body.toString())) return res.sendStatus(400);
// answer 2xx quickly and do the work after — anything else is retried
# Python
import hmac, hashlib, time
def verify(secret, header, raw_body):
if not isinstance(header, str) or not header: return False # no header → not ours
p = dict(kv.strip().split('=', 1) for kv in header.split(',') if '=' in kv)
if not p.get('v1') or not p.get('t', '').isdigit(): return False
if abs(time.time() - int(p['t'])) > 300: return False
expected = hmac.new(secret.encode(), f"{p['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, p['v1'])
Retries: right away, then about 30 s, 1, 2, 4 and 8 minutes later — six tries in about fifteen and a half minutes. A delivery that never lands shows as failed in the panel with a resend button, and GET /api/v1/events lists recent events so you can reconcile. Rotate the secret from the panel after updating your server — the old one stops verifying immediately.
Sign in with AtlasClicks
Standard OAuth 2 authorization code, with PKCE for apps that cannot keep a secret (single-page and mobile apps). Create an app in the panel to get a client ID and client secret; register the exact redirect URLs you will use. People see your store’s name and logo on the consent screen.
1. Send them to AtlasClicks
https://atlasclicks.com/oauth/authorize
?response_type=code
&client_id=app_…
&redirect_uri=https://yourapp.com/auth/atlasclicks/callback // exactly as registered
&scope=openid%20email%20access
&state=… // random, checked on return
&code_challenge=…&code_challenge_method=S256 // PKCE (required without a client secret)
| Scope | Gives you |
|---|---|
openid | A stable user id (sub). |
email | Their email and whether it is verified. |
profile | Display name and picture, when they have one. |
access | GET /api/v1/me/access — what they own from your store. |
They sign in (or create an account) and approve. AtlasClicks sends them to your redirect URL with ?code=ac_…&state=…, or ?error=access_denied if they cancel. Codes work once and expire after ten minutes.
2. Swap the code for tokens
POST https://atlasclicks.com/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=ac_…&redirect_uri=https://yourapp.com/auth/atlasclicks/callback
&client_id=app_…&code_verifier=… // PKCE — or client_secret=sk_app_… from a server (HTTP Basic works too)
{ "access_token": "at_…", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "rt_…", "scope": "openid email access" }
3. Use the token
GET https://atlasclicks.com/oauth/userinfo
Authorization: Bearer at_…
{ "sub": "…", "email": "buyer@example.com", "email_verified": true, "name": "Jordan Lee", "picture": null }
GET https://atlasclicks.com/api/v1/me/access?product=ck_vip
Authorization: Bearer at_…
{ "user": "…", "has_access": true, "status": "active", "kind": "subscription", "product": "ck_vip", … }
Access tokens last an hour; refresh with grant_type=refresh_token&refresh_token=rt_…&client_id=… (plus the client secret unless the grant used PKCE) — each refresh rotates both tokens and the old pair stops working. Store sub on your side; later, your server can check access without the person present via GET /api/v1/access?user=<sub> with an API key. Deleting the app in the panel invalidates every token it holds.
Connect a store (the Approve screen)
For an app that sells through someone’s AtlasClicks store — theirs or yours — without anyone pasting an API key. Ask for a store permission in the same sign-in link and AtlasClicks shows the store owner an Approve screen: your app and their store side by side, a store picker (their own businesses), and each permission ticked — they can untick one. Approve sends them back with a code like any sign-in; the token you get reads that store through the same API as a key would.
| Scope | The owner sees | Your token can call |
|---|---|---|
products:read | See your products and prices | GET /api/v1/products |
customers:read | See who bought them | GET /api/v1/customers |
https://atlasclicks.com/oauth/authorize?response_type=code&client_id=app_…
&redirect_uri=https://yourapp.com/admin/atlasclicks&scope=products:read%20customers:read
&state=…&code_challenge=…&code_challenge_method=S256
POST https://atlasclicks.com/oauth/token (as above)
{ "access_token": "at_…", "refresh_token": "rt_…", "expires_in": 3600,
"scope": "products:read customers:read", "store": { "slug": "yourstore", "name": "Your Store" } }
GET https://atlasclicks.com/api/v1/store → { "slug": "yourstore", "name": "Your Store", "logo": null, "scopes": [ … ] }
GET https://atlasclicks.com/api/v1/products → the store’s products, as with a key
Authorization: Bearer at_…
The connection lasts until someone ends it: refresh tokens for a store last a year and each refresh starts a new year. The owner can Disconnect your app any time under My Store → Apps → Connected apps — your tokens then answer 401 invalid_token. To disconnect from your side, send POST /oauth/revoke with token=rt_…&client_id=app_… (plus the client secret unless the grant used PKCE); the store’s list drops your app. Store permissions only look — they can’t change a store, move money or see payouts.
Sending people to pay
No access? Send them to your pay link — every product has one (https://atlasclicks.com/pay/ck_…, also in /api/v1/products). Add ?return= with a URL on your app and the paid page shows a Continue to yourapp.com → button that brings them straight back (with atlas_paid=1 added). The return URL must sit on an origin you registered — an app’s redirect URL or homepage, or your webhook endpoint — so the page can never be used as an open redirect.
https://atlasclicks.com/pay/ck_vip?return=https%3A%2F%2Fyourapp.com%2Fwelcome
Put {CHECKOUT_SESSION_ID} in the return URL and the button fills in the Stripe checkout’s id, as Stripe’s own success URLs do — handy when your server checks the payment itself (?return=https://yourapp.com/welcome?session_id={CHECKOUT_SESSION_ID}). A ?ref= on the pay link (letters, digits, - and _, up to 64) rides along in the checkout’s Stripe metadata as tickfills_uid — your own id for the person, if you have one.
The webhook (purchase.created) fires when they pay, usually before they are back on your site; the access check answers has_access: true from that moment.
Atlas connectors
The other direction. Atlas is the assistant buyers chat with in the AtlasClicks app; a connector lets it call your API while a chat is about your shop (when that is) — a planner, a booking system, a quote tool. You give it the link to your API’s OpenAPI JSON and a key in My Store → Apps → Atlas connectors; every operation in the spec becomes something Atlas can do for a buyer, and every answer is shown to them as coming from your service. AtlasClicks reviews each connector before it goes live.
What the spec needs
| Rule | Detail |
|---|---|
| OpenAPI 3, as JSON | One address that answers the document itself, with paths in it. YAML is not read yet — export it as JSON. 1 MB at most; the address must not redirect. |
| A public host | https only, a real host name (no IP addresses, no localhost) that resolves to a public address. Checked when the spec is read and again before every call. |
| Where calls go | servers[0].url, else the spec’s own origin. A relative server URL is resolved against the spec address. |
| Which operations | get, post, put, patch, delete — the first 12 in the document, deprecated ones skipped. Switch off any you don’t want Atlas to use, in the sheet. |
| Names and descriptions | A tool is named after its operationId (else method + path). Its summary — or description — is what tells Atlas when to use it, so write it for a reader: “Price a frame from a size and a moulding” beats “POST quote”. |
| Inputs | Path and query parameters plus the top-level fields of a JSON request body become one flat set of inputs — types, descriptions, enums, arrays, objects two levels deep, $ref resolved. Path parameters and anything marked required stay required. |
A spec Atlas reads well
{
"openapi": "3.0.3",
"info": { "title": "Acme Framing", "version": "1" },
"servers": [ { "url": "https://api.example.com/v1" } ],
"paths": {
"/quote": {
"post": {
"operationId": "priceAFrame",
"summary": "Price a custom frame for a piece of art from its width, height and moulding — a materials list and a total.",
"requestBody": { "required": true, "content": { "application/json": { "schema": {
"type": "object", "required": ["width_in", "height_in"],
"properties": {
"width_in": { "type": "number", "description": "Artwork width, inches" },
"height_in": { "type": "number", "description": "Artwork height, inches" },
"moulding": { "type": "string", "enum": ["black", "oak", "walnut"] }
} } } } },
"responses": { "200": { "description": "The quote" } }
}
},
"/slots": {
"get": {
"operationId": "openDeliveryDates",
"summary": "Delivery dates with room in the next two weeks, for a ZIP code.",
"parameters": [
{ "name": "zip", "in": "query", "required": true, "schema": { "type": "string", "x-atlas": "zip" } }
],
"responses": { "200": { "description": "The dates" } }
}
}
}
}
What a call looks like
AtlasClicks’ server makes the call — never the buyer’s phone — with your key in the header you chose, the path and query parameters filled in, and a JSON body of the fields it knows. Answer with JSON; plain text works too.
POST https://api.example.com/v1/quote
Authorization: Bearer <your key> // or x-api-key: <your key>, or no key
Content-Type: application/json
User-Agent: AtlasClicks-Atlas/1.0 (+https://atlasclicks.com)
{ "width_in": 16, "height_in": 20, "moulding": "walnut" }
200 { "total": 240, "materials": [ … ] } // shown to the buyer as your service’s answer
Thirty seconds per call, no redirects, and at most 24 KB of the answer is read (a longer one is cut and marked as such). Something slower, a big render say: answer with the price straight away and let the picture finish at its address (image, below). A non-2xx status, a timeout or a refused address comes back to Atlas as an error and it tells the buyer it couldn’t reach your service. Answers are treated as information, never as instructions — text in a response cannot steer Atlas. A web address in your answer reaches the buyer as a tappable link in Atlas’s reply; a picture is shown when you send it as image (below) — anything else, a PDF say, is best returned as a link to a page of yours.
Quotes and pictures
Two keys in your answer do more than inform. When the seller has switched on Can quote prices for the connector (in the sheet, with a cap), a quote becomes a purchase card in the chat — a one-off pay link for exactly that amount, paid to the seller through AtlasClicks checkout like any product (AtlasClicks keeps 3% of a quote sale; Stripe’s own fee is separate) — and an image is shown as a picture. Everything else in the answer is read as before.
200 {
"total": 240, "materials": [ … ],
"quote": { "amount": 240, "name": "16 × 20 frame", "note": "Walnut, ready in 5–7 days", "ref": "Q-1042",
"shipping": true, "phone": true },
"image": "https://cdn.example.com/renders/8f2a.png"
}
quote.amount | Dollars, the way prices are typed everywhere in AtlasClicks — 240 is $240.00; 49.5 works. At least $1, at most the seller’s cap — $5,000 until the seller changes it in the sheet, and never more than $50,000. Over the cap, or quotes switched off, Atlas is told why and no card appears. |
quote.name | What the buyer is paying for, up to 80 characters — it becomes the product name on the card, the receipt and the seller’s Sales and Customers pages. Name a piece the same way every time, its sizes aside, and put options like the wood or the color in the note: the name with its sizes left out is how a new price for the same piece is told from another piece (Replacing, below). |
quote.ref | Optional, up to 100 characters — your own id for this quote, an order or job number. It comes back in the purchase.created webhook (and the API’s purchases) as "quote": { "ref": "Q-1042", "connector": "Acme Framing" }, so you know which quote was paid. |
quote.note | Optional, up to 300 characters — shown on the card and the checkout page (“Ready in 5–7 days”). |
quote.shipping | Optional, true — the buyer gives a delivery address (US), once: it is saved on their AtlasClicks account and used for every later quote that asks, and the card shows where it goes before they pay (paid in a browser instead, Stripe’s checkout page asks for it). It comes back in purchase.created as "shipping": { "name": …, "address": { "line1", "line2", "city", "state", "postal_code", "country" } } (and on the purchase in /api/v1/customers), so there is no need to ask for a ZIP code or an address in the chat. Leave it out for pickups, bookings and anything digital. |
quote.phone | Optional, true — a phone number too, saved the same way, for the courier or to arrange a delivery. It comes back as customer.phone. |
image | An https address on a public host; the app fetches it straight from you, so it must be reachable without a key. Shown at 16:10, tap to open full size. Send one on its own, or with a quote. For a heavy render send two: "image": { "thumbnail": "https://…/small.jpg", "url": "https://…/full.jpg" } — the small one draws the card at once, the full one opens on tap. |
files | Up to five downloads — "files": [ { "name": "Spec sheet", "url": "https://…/8f2a.pdf" } ] — shown as rows under the picture, opening in the browser. The type comes from the address (.pdf, .csv…) or a type field; https on a public host, like the picture. |
| Replacing | A new quote replaces the last unpaid quote this connector gave in the same chat for the same piece — its card steps aside and can no longer be paid. The same piece is the same name with the sizes left out: “Shaker pantry 46×24×96” and “Shaker pantry 40×24×96” are one piece, a window seat is another. So when the buyer says “make it 40 inches” or “make it oak instead”, just answer again with the new price. Two pieces priced in one answer — a pantry and a window seat — both stay payable, each with its own card: within one answer, only the very same name replaces. |
| Expiry | A quote is good for 24 hours. After that the card says so and the buyer asks Atlas for a fresh one. |
| Paid | The sale lands in the seller’s Sales and Customers like any product, refunds included; the buyer sees the seller’s “after they pay” line from the sheet. Your webhook gets purchase.created with the quote as the product, your ref under quote, and the delivery address and phone when the quote asked for them. AtlasClicks keeps 3% of the sale; Stripe’s own fee is separate. No payment ever touches your API. |
| Review | Switching quotes on sends the connector back for review, since money is now involved. Changing the cap or the after-line keeps its approval. |
The buyer’s photo
An operation can take the photo the buyer attached in the chat — “here’s the print, what frame would suit it?” Mark one string field in the spec with "x-atlas": "photo", in the JSON body or as a query parameter. Atlas never sees or fills that field: the first time your operation is called in a conversation, the buyer gets a card — Send your photo to Acme Framing? — and only after they tap it does AtlasClicks put a link to their photo in the field and make the call. The OK holds for that conversation, so a follow-up (“make it oak instead”) reuses the photo without asking again.
"requestBody": { "content": { "application/json": { "schema": {
"type": "object", "required": ["photo", "width_in"],
"properties": {
"photo": { "type": "string", "format": "uri", "x-atlas": "photo", "description": "A photo of the artwork" },
"width_in": { "type": "number" }
} } } } }
// what arrives, once the buyer allowed it
{ "photo": "https://atlasclicks.com/atlas/photo/8f2a…?s=…", "width_in": 16 }
| The link | Works for 10 minutes, then answers 403 — fetch the photo during the call, not later. No key needed; the signature in the address is the key. Don’t log the address. |
| What you get | A JPEG (sometimes PNG or WebP), up to about 2 MB, longest side 1,600 px — the copy the buyer sent Atlas, unchanged. The latest photo they attached in the conversation. |
| Without a photo | If the buyer has not attached one, the call is made without the field (even when the spec marks it required) — answer with what you can, or an error message Atlas will relay. |
| Your side | It is the buyer’s photo: keep it only as long as the job needs, don’t reuse it, and say so in your privacy policy. The connector sheet shows which of your tools take a photo, and the review looks at it. |
The buyer’s ZIP
For a price that depends on where it ships — delivery dates, a shipping rate — mark one string field "x-atlas": "zip", as a query parameter or in the JSON body. When the buyer has saved a delivery address and left Let shops use my ZIP to price shipping on, AtlasClicks fills that field with its ZIP and Atlas never asks for it. When they haven’t, or it’s going somewhere else (a gift), Atlas asks in the chat and passes the ZIP they give.
{ "name": "zip", "in": "query", "required": true,
"schema": { "type": "string", "x-atlas": "zip" } }
// what arrives
GET https://api.example.com/v1/slots?zip=78701
| What you get | A five-digit US ZIP code, as a string ("78701") — never the street, the name or the phone; those come only with a paid quote that asks for them (Quotes and pictures). |
| The quote | A quote remembers the ZIP it was priced for and hands it back in purchase.created as quote.zip. If the buyer asks for another ZIP, Atlas calls you again and the new quote replaces the old one. |
| At checkout | The saved address is used only when it is in the ZIP you priced for; otherwise the buyer types the address on the checkout page. If your price depends on it, compare shipping.address.postal_code with quote.zip. |
| Without a ZIP | No saved ZIP and none said: Atlas asks before calling. Called without it anyway, answer with an error message and Atlas relays it. |
Good to know
| When Atlas can call you | From the moment Atlas opens your shop in a conversation — the buyer names your shop, taps it in a list Atlas showed, or asks about one of your products — to the end of that conversation; never in one that hasn’t opened it. A conversation keeps the connectors of the last six shops it opened, so a buyer comparing a few shops still reaches yours. Within that, Atlas calls a tool when its summary fits what the buyer asked. Only a live connector counts: approved, switched on, with at least one tool left on. |
| What Atlas sends | Only what the buyer said in the chat — never their email, card or address unless they ask it to — their saved ZIP only while they let shops use it (above), and their photo only after they tap Send. Buying still goes through AtlasClicks checkout; a connector cannot take payment. A delivery address or phone reaches you only with a paid quote that asked for it — the buyer sees where it goes on the card first (above). |
| Testing | Test the connection in the sheet reads your spec and lists the operations it found; tap one to leave it out. Saving reads the spec again on the server, so what Atlas can call is what the server saw — never what a browser claimed. |
| Review | A new connector — or one whose spec address changed — is Pending review until AtlasClicks approves it. Changing the name, the description, the key or the switched-off tools keeps its approval. A rejected one shows the reviewer’s note: fix it and save again to resubmit. |
| Your key | Stored encrypted, shown to no one, sent only from AtlasClicks’ server. Give the connector a key that can only do what it needs, and rotate it on your side any time — paste the new one in the sheet. |
| Limits | 5 connectors per business, 12 operations each. Each call spends the buyer’s normal Atlas credits; you are charged nothing. |
| Not yet | YAML specs, MCP servers, OAuth, files going to you other than the photo. Keys go as Authorization: Bearer or x-api-key, or not at all. |