Developer docs
Experimental

Build with Purpleturret.

Sync subscription access into your product with one HTTP call. Get notified when anything changes via signed webhooks.

Heads up: the API and webhooks are still under active development. Expect bugs, and please use them for experimental projects only for now. We'll let you know when everything is production-ready.

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.

Routing 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.

Never embed an API key in a mobile app, browser bundle, or anything else that ships to a customer. Always call the entitlements API from a server you control.

Entitlements API

One endpoint. Returns whether a given customer has access to a given app.

Endpoint

GET https://api.purpleturret.com/v1/entitlements

Headers

Authorization: Bearer pt_sk_...

Query parameters

  • group_key (required) — the app's app_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 be has_access: false).
  • 400missing_group_key or missing_customer_identifier.
  • 401unauthorized. Check the Bearer token.
  • 404group_not_found or no subscription found for that customer.
  • 429rate_limited. The response includes a retry-after header (in seconds) and a reset_at field (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)
Use the raw request body, not a re-serialized JSON object. Re-serializing reorders keys and breaks the 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.