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://portal.topiic.com.au/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.
purintPurpose of the checkout. 0 (or omitted) = enrol a new member in plan — the default flow. 1 = update payment method for an existing member (see below).
memUUID stringRequired when pur is 1: the existing member whose default payment method you’re replacing. Must belong to mid. Ignored when pur is 0.
{
"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
}

The same signed-link mechanism can send an existing member back through the hosted card-capture form to replace their default payment method — no new subscription is created. Set pur to 1 and put the member’s Topiic id in mem; plan is ignored for this purpose.

{
"akid": "9b5d4d80-0e1a-4b3a-9a4f-2b1c6c8a9d0e",
"mid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"plan": "00000000-0000-0000-0000-000000000000",
"ref": "cust_42",
"pur": 1,
"mem": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"ret": "https://your-app.example.com/card-updated?customer=42",
"evts": ["payment_method.updated"],
"nonce": "b1946ac92492d2347c6235b4d2611184",
"exp": 1769472000
}

On completion Topiic fires payment_method.updated (to the originating API key) rather than checkout.completed. Resolve-time validation is the same as the enrol flow, with two differences: a missing/empty mem is rejected as 401, and a mem that doesn’t exist for mid is rejected as 404.

  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://portal.topiic.com.au/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://portal.topiic.com.au') {
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.