Authentication
Your server calls unPLUG with a secret key. Your app calls unPLUG with a member token that your server hands it after your own sign-in. Set up both, and keep the secret key on your server.
Available at launch
Two credentials#
| Credential | Looks like | Where it lives | What it can do |
|---|---|---|---|
| Secret key | uk_live_...uk_test_... | Your server only | Every server call its scopes allow, for one brand. Test keys work only on the sandbox host, live keys only on production. |
| Member token | mt_... | Your app or web page | Calls about one member, for 15 minutes. |
Both go in the same header:
Authorization: Bearer uk_live_... # from your server
Authorization: Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS # from your appThere's a third secret, the webhook signing secret (whsec_...). It doesn't authenticate requests; it verifies the webhooks we send you. See Webhooks.
Warning Never put a secret key in an app, a web page or a public repo. Anyone who has it can create orders and move points for your whole brand.
Scopes#
Each secret key carries a set of scopes. A full-access key has all of them. A call outside the key's scopes returns 403 missing_scope.
| Scope | Allows |
|---|---|
program:read | Read the program, locations and the rewards catalog. |
members:read | Read and look up members, their activity and wallet. |
members:write | Create, update and delete members, change communication preferences, revoke a member's tokens, send app events from your server. |
member_tokens:create | Mint member tokens with POST /v1/member_tokens. |
points:adjust | Add or remove points with POST /v1/members/{id}/points_adjustments. Management API. |
checkout:write | Previews, redemptions, orders and refunds. |
webhooks:manage | Create and change webhook endpoints, read the event log, redeliver events. |
offers:write | Create, publish and grant offers. Management API. Coming soon |
challenges:write | Create and run challenges. Management API. Coming soon |
reports:read | Read reports. Management API. Coming soon |
Give each service the smallest set it needs. An app backend that signs guests in and posts orders needs program:read, members:read, members:write, member_tokens:create and checkout:write. It never needs the Management API scopes, because the unPLUG Dashboard does that work. A guest support tool might only need members:read and points:adjust. Ask support for one key per service so you can revoke one without touching the others.
Calling for a member from your server#
Almost every App API call a member token can make also works from your server with a secret key, so you can run a server-to-server integration where the app only talks to you. The server versions take the member in the path or in a member_id field. Registering a device, recording a push open and showing the in-store code stay in the app, because they belong to the phone.
Warning With a secret key, unPLUG trusts whatever member id you send. Always take it from your own signed-in session, never from a value the app or browser sends, or one guest could read or spend another guest's points.
What a member token can call#
GET /v1/program,GET /v1/locations,GET /v1/rewards- Everything under
/v1/members/me/...: profile, activity, wallet, devices, messages, communication preferences POST /v1/checkouts/previewandPOST /v1/redemptionsfor its own memberGET /v1/redemptions/{id}andPOST /v1/redemptions/{id}/cancelfor its own member's redemptions, so the app can release a hold when the guest leaves checkoutPOST /v1/app_events
A member token can't create orders, adjust points, change the member's phone number or read any other member. Those calls need your server.
The member token flow#
Your guests sign in with your own login. unPLUG never sees their password. Once they're signed in, your server vouches for them by asking unPLUG for a token tied to their member.
- The guest signs in to your app with your own login: password, social sign-in, SMS code, whatever you use today.
- Your app asks your server for a member token, on a route you add, for example
POST /loyalty/token. Your server checks the guest's session like it does for any other route. - Your server calls
POST /v1/member_tokenswith the secret key and the guest's memberid(or yourexternal_idfor them). - unPLUG returns a token that lasts 15 minutes. There is no refresh token.
- Your server passes the token and
expires_atto the app. - Your app calls unPLUG directly with
Authorization: Bearer mt_...to show the balance, wallet and rewards, preview a cart and register for push.
Mint a token#
curl -X POST https://api.unplugdining.com/v1/member_tokens \
-H "Authorization: Bearer $UNPLUG_KEY" \
-H "Content-Type: application/json" \
-d '{ "member_id": "3f2b8c1e-6d0a-4e7b-9f3c-2a1d5e8b7c40" }'const response = await fetch("https://api.unplugdining.com/v1/member_tokens", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.UNPLUG_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ member_id: "3f2b8c1e-6d0a-4e7b-9f3c-2a1d5e8b7c40" }),
});
const data = await response.json();import os
import requests
response = requests.post(
"https://api.unplugdining.com/v1/member_tokens",
headers={
"Authorization": f"Bearer {os.environ['UNPLUG_KEY']}",
},
json={"member_id": "3f2b8c1e-6d0a-4e7b-9f3c-2a1d5e8b7c40"},
)
data = response.json(){
"token": "mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS",
"expires_at": "2026-11-03T18:37:11Z",
"member_id": "3f2b8c1e-6d0a-4e7b-9f3c-2a1d5e8b7c40"
}You can send { "external_id": "usr_10482" } instead of member_id. The key needs the member_tokens:create scope. A deleted member returns 410 member_deleted, and an unknown one returns 404 member_not_found.
The route on your server#
The whole route is a session check and one call. These examples assume your session middleware already loaded the signed-in user and that you stored their unPLUG member id at sign-up.
const UNPLUG_API = process.env.UNPLUG_API; // https://api.unplugdining.com/v1
const UNPLUG_KEY = process.env.UNPLUG_KEY; // uk_live_..., never shipped to the app
app.post("/loyalty/token", requireSession, async (req, res) => {
const memberId = req.user.unplugMemberId;
if (!memberId) return res.status(404).json({ error: "not_enrolled" });
const r = await fetch(`${UNPLUG_API}/member_tokens`, {
method: "POST",
headers: {
Authorization: `Bearer ${UNPLUG_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ member_id: memberId }),
signal: AbortSignal.timeout(10_000),
});
if (!r.ok) {
const problem = await r.json().catch(() => ({}));
console.error("member token failed", r.status, problem.code, r.headers.get("request-id"));
return res.status(502).json({ error: "loyalty_unavailable" });
}
const { token, expires_at } = await r.json();
res.set("Cache-Control", "no-store").json({ token, expires_at });
});import os
import requests
from flask import jsonify, abort
UNPLUG_API = os.environ["UNPLUG_API"] # https://api.unplugdining.com/v1
UNPLUG_KEY = os.environ["UNPLUG_KEY"] # uk_live_..., never shipped to the app
@app.post("/loyalty/token")
@login_required
def loyalty_token():
member_id = current_user.unplug_member_id
if not member_id:
abort(404)
r = requests.post(
f"{UNPLUG_API}/member_tokens",
headers={"Authorization": f"Bearer {UNPLUG_KEY}"},
json={"member_id": member_id},
timeout=10,
)
if not r.ok:
app.logger.error("member token failed %s %s %s",
r.status_code, r.json().get("code"), r.headers.get("Request-Id"))
abort(502)
body = r.json()
resp = jsonify(token=body["token"], expires_at=body["expires_at"])
resp.headers["Cache-Control"] = "no-store"
return respTake the member id from the session, never from the request body. If the app could choose the member, any signed-in guest could get a token for someone else.
Use and renew the token in your app#
Keep the token in memory. It lasts 15 minutes, so there's no reason to write it to disk. When it expires, unPLUG returns 401 with code token_expired; ask your server for a new one and retry the call once.
let current = null; // { token, expires_at }
async function memberToken({ force = false } = {}) {
const stillGood = current && Date.parse(current.expires_at) - Date.now() > 60_000;
if (!force && stillGood) return current.token;
const r = await fetch("/loyalty/token", { method: "POST", credentials: "include" });
if (!r.ok) throw new Error("loyalty token unavailable");
current = await r.json();
return current.token;
}
async function unplug(path, init = {}) {
const call = (token) => fetch(`https://api.unplugdining.com/v1${path}`, {
...init,
headers: { ...init.headers, Authorization: `Bearer ${token}` },
});
let r = await call(await memberToken());
if (r.status === 401) {
const problem = await r.clone().json().catch(() => ({}));
if (problem.code === "token_expired") r = await call(await memberToken({ force: true }));
}
return r;
}
// const me = await (await unplug("/members/me")).json();Renewing a minute early saves a round trip on most calls; the 401 path covers the rest, for example after the app wakes from the background. If a fresh token also gets a 401, stop and show a signed-out loyalty state instead of looping.
Sign out and revoke#
- Sign out on one device. The app calls
DELETE /v1/members/me/devices/{device_id}while it still has a token, then forgets the token. Without the device delete, the next guest on that phone could get the previous guest's pushes. - Sign out everywhere. Your server calls
POST /v1/members/{id}/revoke_tokens. Every member token issued for that member stops working at once. Use it when a guest changes their password or reports a lost phone.
curl -X POST https://api.unplugdining.com/v1/members/3f2b8c1e-6d0a-4e7b-9f3c-2a1d5e8b7c40/revoke_tokens \
-H "Authorization: Bearer $UNPLUG_KEY"const response = await fetch("https://api.unplugdining.com/v1/members/3f2b8c1e-6d0a-4e7b-9f3c-2a1d5e8b7c40/revoke_tokens", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.UNPLUG_KEY}`,
},
});
const data = await response.json();import os
import requests
response = requests.post(
"https://api.unplugdining.com/v1/members/3f2b8c1e-6d0a-4e7b-9f3c-2a1d5e8b7c40/revoke_tokens",
headers={
"Authorization": f"Bearer {os.environ['UNPLUG_KEY']}",
},
)
data = response.json()Revoking doesn't stop your server from minting a new token. Make sure your own session is gone too, or the app will get a new token on its next request.
Rotate a key#
Two secret keys can be active at once, so you can rotate without downtime.
- Ask support for a new key with the same scopes. Say which key it replaces by its environment and the service using it.
- Deploy the new key to every server and job that uses the old one.
- Watch your logs until no request goes out with the old key.
- Ask support to revoke the old key. Calls with it then return
401 invalid_api_key.
Rotate when someone with access to the key leaves, and any time you suspect a leak.
IP allowlist#
You can lock a key to your servers' egress IPs. Requests with that key from any other address are rejected. Send support the IPs or CIDR ranges and the key they apply to. Keep the list current before you move servers or change your NAT gateway; otherwise your own calls get rejected.
If a key leaks#
Treat any key that reached an app build, a web bundle, a public repo, a ticket or a chat as leaked, even if you deleted it afterwards.
- Email support right away at support@unplugdining.com with a subject starting "Security:". Say which key (environment and service), where it leaked and when. Never include the key itself.
- Ask for a replacement key in the same email. We revoke the leaked key within one hour on business days.
- Deploy the replacement as soon as you get it.
- Check what the key did. Look for unexpected
adjustentries in member activity (every adjustment records the key that made it) and for orders you don't recognize. Send us anything odd with itsRequest-Id.
Coming soon We'll join GitHub's secret scanning partner program, so a key pushed to a public repo is reported to us automatically.
unPLUG-hosted sign-in#
Coming soon
For brands that don't have their own login. Your app opens an unPLUG sign-in page with your logo in the system browser (ASWebAuthenticationSession on iOS, Custom Tabs on Android), the guest enters their phone number and an SMS code, and your app gets a member token plus a refresh token.
- OAuth 2.0 authorization code flow with PKCE, per RFC 8252 for native apps.
- Authorize:
GET https://auth.unplugdining.com/authorize - Token:
POST https://auth.unplugdining.com/token - Refresh tokens rotate: each refresh returns a new one and the old one stops working.
If you have your own login, use the member token flow above. It's available now and keeps the guest in one account system.
Developer dashboard#
Coming soon
A self-serve page for credentials: create, roll and revoke keys, manage webhook endpoints, upload push credentials and read request logs. It manages credentials only. Program settings such as earn rate, tiers and rewards stay in the unPLUG dashboard.
What needs support today#
Until the developer dashboard ships, email support@unplugdining.com for:
- Sandbox and live keys, extra keys per service, and scope changes
- Rotating or revoking a key, and leaks (subject starting "Security:")
- IP allowlists
- Higher rate limits
- Push credentials (through the secure upload link we send, never as an attachment)
Include your brand name, the environment and any Request-Id. We never ask for a secret key, so never send one.
Checklist#
- The secret key is only on your server, loaded from a secrets manager or environment variable.
- Your token route, and every server call that names a member, takes the member from the session, not from the request.
- The app keeps the token in memory and renews on
401 token_expired, once. - Sign out deletes the device, then forgets the token.
- Password changes and account locks call
revoke_tokens. - You've written down how to rotate a key and who emails support if one leaks.