Overview
Purpleturret connects three things: apps (a grouping of checkout links you sell), API keys (used by your server to check who has access), and webhook endpoints (where we POST events when subscriptions change). You configure all three in Settings → Developers.
The fastest path to integrating: create an app, attach the checkout links that should unlock it, mint an API key, and call the entitlements API from your server when a customer signs in. Add a webhook later if you want push updates.
Versioning
The HTTP API is versioned in the URL: /v1/entitlements. Breaking changes ship behind a new path (/v2/...); the /v1 path stays stable.
Webhook payloads include an api_version field (currently "2026-05-16"). Treat it as a contract: when you see a new value, check the changelog before assuming the payload shape is the same. Additive fields can show up without a version bump; existing fields will not be removed or change type within the same api_version.
Apps & links
Two concepts to keep straight:
- An app is the product you sell — for example, Acme Editor. When your code asks "does this customer have access?", it asks per app.
- A link is one way to buy access to that app — a specific price, billing interval, and tier. Acme Pro · Monthly and Acme Pro · Yearly are two different links that both unlock the same app at the same tier.
You'll typically have several links per app:
Acme Editor (app · app_key: "acme_editor")
├── Acme Pro · Monthly (link · tier: Pro, rank: 50)
├── Acme Pro · Yearly (link · tier: Pro, rank: 50)
├── Acme Premium · Monthly (link · tier: Premium, rank: 100)
└── Acme Premium · Yearly (link · tier: Premium, rank: 100)Tiers let you tell plans apart inside the same app. Each link has a tier name (a label like Pro) and a tier rank — a number you choose. Higher rank means more access.
Suggested ranks: 10 for basic, 50 for standard, 100 for premium. The actual numbers are up to you. The entitlements API compares ranks when a customer holds more than one subscription and returns the highest one.
tier.rank against a number, not against a name. So gating a feature behind "Premium" becomes tier.rank >= 100. That way renaming a tier in the dashboard doesn't break your gates.Creating a checkout link
Three steps, all in the dashboard:
- Go to Dashboard → Links and click Create a link. Set the name, price, currency, and billing — one-time, monthly, or yearly.
- After saving, your checkout URL looks like
https://purpleturret.com/c/<slug>. The<slug>is derived from the link's name and shown on the link detail page. Use this URL anywhere on your site — pricing page, email, "Upgrade" buttons. - Open Settings → Developers, click your app, then Add link. Pick the checkout link you just created, give it a tier name (e.g.
Pro) and an access level. From now on, anyone who buys through that link unlocks the tier in your app.
The same checkout link can only belong to one app at a time. To move it, open the link in the app sheet and click the trash icon — the link itself stays, it just becomes unassigned.
Pass your own customer id at checkout (optional). If you append ?external_id=YOUR_USER_ID to a checkout URL, we'll store that id with the subscription. You can then look up entitlements by your own id instead of email — useful if customers can change their email.
https://purpleturret.com/c/acme-pro-monthly?external_id=u_42a9b1
https://purpleturret.com/c/acme-pro-monthly?email=ada@example.comRouting users by tier
The pattern is the same whether you're showing pricing, upgrade options, or feature gates: ask the entitlements API what tier the user is on, then send them to the right checkout link.
Server-side gate
// Pseudocode — runs when the user lands on a feature.
const ent = await fetch(
`https://api.purpleturret.com/v1/entitlements?group_key=acme_editor&email=${user.email}`,
{ headers: { Authorization: `Bearer ${process.env.PURPLETURRET_KEY}` } },
).then(r => r.json());
if (!ent.has_access) {
return redirect("/pricing"); // No subscription yet.
}
if (ent.tier.rank < 100) {
return redirect("/upgrade-to-premium"); // On Pro, but feature is Premium-only.
}
// All good — render the feature.A "Choose a plan" page
Keep your pricing page on your own site and link out to each checkout. Hide the plans the user already owns and highlight the upgrade.
<a href="https://purpleturret.com/c/acme-pro-monthly?external_id={user.id}">
Pro · $9/mo
</a>
<a href="https://purpleturret.com/c/acme-pro-yearly?external_id={user.id}">
Pro · $90/yr (save 16%)
</a>
<a href="https://purpleturret.com/c/acme-premium-monthly?external_id={user.id}">
Premium · $19/mo
</a>Don't gate the UI in the browser alone. The entitlements API is the source of truth — always check on the server before serving paid functionality. Use webhooks (next section) to flip access in your database as soon as a payment lands or a subscription is canceled.
API keys
Open Settings → Developers, name your key (e.g. Production server), and click Create key. The raw key is shown once and copied to your clipboard — store it in your server's secret manager. Only a SHA-256 hash is kept.
Keys look like:
pt_sk_3f7b4f02ac5e1d... (64 hex chars after the prefix)You can create as many keys as you want — one per environment is a common pattern. Each key can be renamed or revoked at any time. Revocation takes effect immediately.
Entitlements API
One endpoint. Returns whether a given customer has access to a given app.
Endpoint
GET https://api.purpleturret.com/v1/entitlementsHeaders
Authorization: Bearer pt_sk_...Query parameters
group_key(required) — the app'sapp_key.email— the customer's email. Case-insensitive.external_id— your own customer identifier, if you passed one through at checkout.
You must include email or external_id (you can include both — we'll match on external_id first).
Example request
curl 'https://api.purpleturret.com/v1/entitlements?group_key=acme_saas&email=ada@example.com' \
-H 'Authorization: Bearer pt_sk_...'Response — has access (200)
{
"has_access": true,
"status": "active",
"reason": "active",
"group": { "id": "...", "key": "acme_saas", "name": "Acme SaaS" },
"customer": { "email": "ada@example.com", "external_id": null },
"matched_by": "email",
"active_link": {
"id": "...",
"slug": "acme-pro-monthly",
"name": "Acme Pro · Monthly",
"billing_type": "recurring",
"billing_interval": "month"
},
"tier": { "key": "pro_monthly", "name": "Pro", "rank": 50 },
"subscription": {
"id": "...",
"status": "active",
"cancel_at_period_end": false,
"current_period_end": 1736899200000
},
"current_period_end": 1736899200000
}matched_by tells you which identifier actually located the subscription. If you sent both external_id and email, we try external_id first; if there's no match, we fall back to email. The field is null when no subscription was found.
Response — no subscription (404)
{
"has_access": false,
"status": "none",
"reason": "group_not_found" | "no_subscription"
}Other status codes
200— entitlement found (may behas_access: false).400—missing_group_keyormissing_customer_identifier.401—unauthorized. Check the Bearer token.404—group_not_foundor no subscription found for that customer.429—rate_limited. The response includes aretry-afterheader (in seconds) and areset_atfield (epoch ms). See Rate limits.
Reason codes (the reason field)
active— subscription is active or trialing.past_due_within_paid_period— past due, but the paid period hasn't ended yet.canceled_until_period_end— canceled, but access continues until the period ends.no_subscription— customer found, but no subscription for this app.- Otherwise: the raw subscription status (
incomplete,paused,canceled, etc).
Rate limits
The entitlements API is rate-limited on two axes, both using a 60-second sliding window:
- Per API key: 600 requests / minute.
- Per IP: 1,200 requests / minute (catches pre-auth flooding from a single source).
When you exceed a limit we return HTTP 429 with this body:
{ "error": "rate_limited", "scope": "api_key", "reset_at": 1736899260000 }The retry-after response header tells you how many seconds to wait. Back off, then retry. Both limits reset at the top of each minute, not on a rolling window — short bursts up to the limit are fine.
Need higher limits? Get in touch.
Webhooks
Webhook endpoints let you push subscription changes into your system without polling. Each app supports multiple endpoints — useful when you want to fan out the same events to staging, production, and a logging service.
Add endpoints in Settings → Developers by clicking an app's row to open the editor, then Add webhook. Each endpoint has its own signing secret (a whsec_… string) so you can rotate them independently.
Click Send test event on an enabled endpoint to push a test.event payload immediately and confirm your server is receiving signed requests.
Event types
We deliver these event types to every enabled endpoint:
subscription.created— a new subscription was created.subscription.updated— status, period, or cancel-at-period-end changed.subscription.canceled— the subscription has ended.invoice.paid— an invoice was paid successfully.invoice.payment_failed— an invoice charge failed.test.event— sent from the "Send test event" button. Same shape, dummy data.
Payload shape
{
"id": "evt_...",
"type": "subscription.updated",
"api_version": "2026-05-16",
"created": 1736899200000,
"group": { "id": "...", "key": "acme_saas", "name": "Acme SaaS" },
"customer": { "email": "ada@example.com", "external_id": null },
"link": {
"id": "...",
"slug": "acme-pro-monthly",
"name": "Acme Pro · Monthly",
"billing_interval": "month"
},
"tier": { "key": "pro_monthly", "name": "Pro", "rank": 50 },
"subscription": {
"id": "...",
"status": "active",
"cancel_at_period_end": false,
"current_period_end": 1736899200000
},
"invoice": null,
"access": { "has_access": true, "reason": "active" }
}Headers we send on every request:
Content-Type: application/json
User-Agent: Purpleturret-Webhooks/1.0
Purpleturret-Signature: t=1736899200,v1=<hex hmac-sha256>Verifying signatures
Every webhook request includes a Purpleturret-Signature header with a timestamp and an HMAC-SHA256 signature of `${timestamp}.${rawBody}`. Verify it before trusting the payload.
Node.js
import crypto from "node:crypto";
function verifyPurpleturretSignature(rawBody, header, secret) {
const match = /^t=(\d+),v1=([a-f0-9]+)$/.exec(header ?? "");
if (!match) return false;
const [, timestamp, signature] = match;
// Reject events older than 5 minutes to prevent replay attacks
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (age > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(signature, "hex"),
);
}Python
import hmac, hashlib, re, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
m = re.match(r"^t=(\d+),v1=([a-f0-9]+)$", header or "")
if not m: return False
timestamp, signature = m.group(1), m.group(2)
if abs(int(time.time()) - int(timestamp)) > 300:
return False
expected = hmac.new(
secret.encode(),
f"{timestamp}.{raw_body.decode()}".encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)Idempotency
Webhooks will arrive more than once. A retry succeeds after a network timeout that made us think delivery failed; a load balancer drops the response after your server already committed. Always dedupe on the receiving side using event.id.
The simplest pattern: store the event id, and skip if you've seen it before.
// Node + Postgres
async function handleWebhook(event) {
const inserted = await db.query(
"INSERT INTO processed_events (id, received_at) VALUES ($1, NOW()) " +
"ON CONFLICT (id) DO NOTHING RETURNING id",
[event.id],
);
if (inserted.rowCount === 0) {
// Already processed this event — return 200 so we don't retry.
return;
}
await applyEvent(event);
}Return 2xx for duplicates. If you return an error we'll retry, which makes the problem worse.
If your processing is naturally idempotent (e.g. UPDATE users SET tier = ...) you can skip the dedup table, but you still need to handle out-of-order delivery: two retries for different events may arrive in a different order than the first attempts. Use the event's created timestamp to ignore stale state changes.
Retries
If your endpoint returns a non-2xx status or doesn't respond, we'll retry with the following backoff schedule:
Attempt 1 immediately
Attempt 2 +1 minute
Attempt 3 +5 minutes
Attempt 4 +30 minutes
Attempt 5 +2 hours
Attempt 6 +8 hours
Attempt 7 +24 hours
Attempt 8 +48 hours
Attempt 9 +72 hours
(total window ≈ 6 days, then we give up)Each attempt shows up as a separate row in the Recent deliveries list inside the app's editor with the HTTP status or error we got back. We only persist the status code — never the response body — so receivers can echo whatever they need without leaking anything to us.
If an endpoint exhausts all 9 attempts 5 or more times in a 24-hour window, we'll email the account owner once and then back off for 24 hours before alerting again. Disabling the endpoint stops both deliveries and alerts.
Retention: deliveries and event payloads are kept for 90 days, then purged.
Best practice: respond with 2xx as soon as you've persisted the event to your queue. Long-running processing should happen asynchronously — webhook delivery times out quickly.
Need something that isn't documented here? Get in touch — we read everything.