Skip to content

Signing deep links

The exact payload schema, HMAC algorithm, and edge cases for building hosted-checkout deep links.

A signed deep link is a URL that hands a specific customer to Topiic’s hosted checkout. Topiic verifies the signature, creates a checkout session, and serves the page.

https://pay.topiic.com/c?d=<base64url(payload)>&s=<base64url(HMAC-SHA256(secret, d))>

The payload is a JSON object. Field names are short to keep the URL compact; don’t rename them.

FieldTypeRequiredNotes
akidUUID stringYour API key id. Find it in Settings → API keys (column “Id”).
midUUID stringYour merchant id. Must match the API key’s merchant.
planUUID stringPlan id the user will be enrolled in. Must be active and belong to mid.
refstringYour internal id for this customer. Echoed in webhook payloads as externalRef. ≤200 chars.
contactobjectPrefill for the contact step. All children optional.
contact.fnstringFirst name.
contact.lnstringLast name.
contact.emstringEmail.
contact.phstringPhone.
retURL stringWhere the user is redirected after success/cancel. ?status=ok or ?status=cancelled is appended.
evtsstring[]Webhook event types to deliver for this session. Typically ["checkout.completed", "checkout.failed"].
noncestringSingle-use random token (≥16 bytes, hex or base64url). DB-unique. Replay protection.
expintUnix seconds expiry. Recommended: ≤30 minutes from now. Topiic rejects links past exp with 410 Gone.
{
"akid": "9b5d4d80-0e1a-4b3a-9a4f-2b1c6c8a9d0e",
"mid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"plan": "b6a8a5b8-7b3c-4d1e-9c2a-1f9e8d7c6b5a",
"ref": "cust_42",
"contact": { "fn": "Jane", "ln": "Smith", "em": "jane@example.com", "ph": "+61 400 000 000" },
"ret": "https://your-app.example.com/subscribed?customer=42",
"evts": ["checkout.completed", "checkout.failed"],
"nonce": "7c2d4f8a1b3e9c6d5a8f2b7e4c1d9a3b",
"exp": 1769472000
}
  1. Serialise the payload to compact JSON (no whitespace). Field order doesn’t matter — Topiic deserialises by name.
  2. Base64url-encode the JSON bytes — the standard “URL-safe alphabet” base64 (- and _ instead of + and /) with trailing = padding stripped. Call this d.
  3. HMAC-SHA256 the ASCII bytes of d using the plaintext signing secret (tss_…) as the key. Base64url-encode the 32-byte digest. Call this s.
  4. Build the URL: https://pay.topiic.com/c?d=<d>&s=<s>.
import crypto from 'node:crypto';
function b64url(bytes) {
return Buffer.from(bytes).toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export function buildDeepLink(payload, signingSecret, base = 'https://pay.topiic.com') {
const d = b64url(JSON.stringify(payload));
const sig = crypto.createHmac('sha256', signingSecret).update(d).digest();
const s = b64url(sig);
return `${base}/c?d=${d}&s=${s}`;
}
HTTP statusMeaningWhat to do
200Link resolved; session JWT issued.(Topiic handles this — your server isn’t called.)
400Missing d or s.Bug in your URL builder.
401Invalid signature, or mid doesn’t match akid.Re-check that you signed the exact bytes of d with the right secret.
404API key not found, revoked, or the referenced plan doesn’t exist.Check the API key is still active in the portal; confirm plan is a valid id.
409Nonce already used.Generate a fresh nonce per link.
410Link expired (exp in the past).Reissue with a future exp.

The user sees a friendly Topiic-branded error page; your server is not notified.

  • Generate links on-demand, not in advance. If you pre-generate hundreds with the same exp, they all expire at the same time.
  • Pin exp to 15–30 minutes. Long-lived links increase the window an attacker has to grind nonces.
  • Don’t put PII in the nonce. It’s random opaque bytes, not a customer id.
  • Log the ref alongside the link so support can trace “Jane clicked subscribe at 10:14 — did the link resolve?” against Topiic’s session history.
  • Rotate the signing secret by minting a new API key, dual-running both for a grace period, then revoking the old one. There’s no in-place rotation.

Verifying the signature locally (for tests)

Section titled “Verifying the signature locally (for tests)”

Useful in CI:

const expected = b64url(crypto.createHmac('sha256', secret).update(d).digest());
assert.strictEqual(expected, s, 'signature mismatch');

Constant-time comparison is required server-side (where attacker timing matters). For your own test fixtures, plain === is fine.