Authentication
We use OAuth 2.0 client credentials. You exchange a client id and secret for a bearer token, then send that token on every request.
Environments
Section titled “Environments”| Environment | Base URL | Token endpoint |
|---|---|---|
| Production | https://api.diller.app | https://api.diller.app/connect/token |
| Sandbox (prerelease) | https://api.prerelease.dillerapp.com | https://api.prerelease.dillerapp.com/connect/token |
Build against sandbox. Credentials are not shared between the two — a sandbox client id will not authenticate against production.
OpenID discovery is available at /.well-known/openid-configuration.
Getting credentials
Section titled “Getting credentials”Credentials belong to the merchant, not to you. The merchant finds them in their own Diller account:
Settings → Our Details → API Details
That screen shows:
| Field | Value |
|---|---|
| OAuth token endpoint | https://api.diller.app/connect/token |
| OAuth flow | Client Credentials |
| Scopes | api.retailer |
| Client ID | identifies you at the token endpoint |
| Client Secret | proves it. Treat it as a password |
Ask the merchant to send you both, over something safer than email.
The store id that goes into every URL comes from the API once you have a token — see Finding your store id.
Getting a token
Section titled “Getting a token”POST /connect/token, form-encoded — not JSON.
POST /connect/token HTTP/1.1Host: api.prerelease.dillerapp.comContent-Type: application/x-www-form-urlencoded
grant_type=client_credentials&scope=api.retailer&client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRETYou may instead send the credentials as HTTP Basic:
Authorization: Basic base64(client_id:client_secret)If both are present, the header wins.
Response
Section titled “Response”The response contains exactly four fields:
{ "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6...", "scope": "api.retailer", "expires_in": 3600, "token_type": "Bearer"}expires_in is seconds. One hour is typical; treat the value as
authoritative rather than hardcoding 3600.
Scopes
Section titled “Scopes”| Scope | Grants |
|---|---|
api.retailer | Read/write across members, coupons, stamps, transactions. This is the one you want. |
read:transaction | Read transactions only |
Errors
Section titled “Errors”| Condition | Response |
|---|---|
grant_type is not exactly client_credentials | 400 — “Invalid grant type. Please ensure the grant_type is set to ‘client_credentials’” |
| Wrong id or secret | 400 |
| Right credentials, wrong environment | 400 |
The token endpoint answers errors with a plain-text body, not JSON. Read
it with res.text(); res.json() throws. Every other endpoint returns JSON
errors — see the error shapes.
Using the token
Section titled “Using the token”GET /api/v2.0/stores/{storeId}/members/search?phone=%2B4799900001 HTTP/1.1Host: api.prerelease.dillerapp.comAuthorization: Bearer eyJhbGciOiJSUzI1NiIs...Content-Type: application/jsonFinding your store id
Section titled “Finding your store id”Every resource URL carries a {storeId} path segment. Read it from the API:
GET /api/v2.0/stores HTTP/1.1Host: api.prerelease.dillerapp.comAuthorization: Bearer eyJhbGciOiJSUzI1NiIs...[ { "id": "Ab3xK_9pQrLm", "store_name": "Nordmann Sport Oslo", "currency": "NOK", "time_zone": "Europe/Oslo" }]The array always holds exactly one store: the one your credentials belong
to. Its id matches your client id, and a request with any other store id
in the path is refused with 403. Take id and keep it in configuration
next to the base URL and the client id. Do this once, at setup — not on
every request. Keep time_zone too; coupon validity and naive timestamps
are both read in the store’s timezone.
Store ids are case-sensitive. Ab3xK_9pQrLm and ab3xk_9pqrlm are not
the same store. Do not lowercase them anywhere in your config.
Caching and refresh
Section titled “Caching and refresh”Do not fetch a token per request. Cache it, reuse it until it is nearly
expired, and refresh on demand. Also retry once on a 401 — a token
can be invalidated before its stated expiry.
A minimal implementation:
let cached = null; // { token, expiresAt }
async function getToken({ force = false } = {}) { const skewMs = 60_000; // refresh a minute early if (!force && cached && cached.expiresAt - skewMs > Date.now()) { return cached.token; } const res = await fetch(`${BASE}/connect/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', scope: 'api.retailer', client_id: CLIENT_ID, client_secret: CLIENT_SECRET, }), signal: AbortSignal.timeout(5_000), }); // Token errors are plain text, not JSON. if (!res.ok) throw new Error(`Token request failed: ${res.status} ${await res.text()}`); const body = await res.json(); cached = { token: body.access_token, expiresAt: Date.now() + body.expires_in * 1000, }; return cached.token;}
export async function dillerFetch(path, init = {}, { timeoutMs = 2_000, retrying = false } = {}) { const token = await getToken(); const res = await fetch(`${BASE}${path}`, { ...init, headers: { ...init.headers, Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, signal: AbortSignal.timeout(timeoutMs), }); if (res.status === 401 && !retrying) { await getToken({ force: true }); return dillerFetch(path, init, { timeoutMs, retrying: true }); } return res;}The two-second default suits calls on the cart and checkout path, where a
slow answer must not hold up the page. Pass a longer timeoutMs for
background work such as posting transactions from a queue. A timeout throws
a TimeoutError — catch it and degrade
gracefully.
Security requirements
Section titled “Security requirements”Practical rules:
- Expose your own endpoints (
/api/loyalty/…) and call us from behind them. - Authorise every request against the logged-in session. Never let the browser pass a member id you then trust — a customer could enumerate other members’ coupons and points.
- Store the secret in your secrets manager or environment, not in the repo.
- We rotate the secret for you. Ask before you need it, not during an incident.
