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 string✓Your API key id. Find it in Settings → API keys (column “Id”).
midUUID string✓Your merchant id. Must match the API key’s merchant.
planUUID string✓Plan id the user will be enrolled in. Must be active and belong to mid.
refstring✓Your 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 string✓Where 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"].
noncestring✓Single-use random token (≥16 bytes, hex or base64url). DB-unique. Replay protection.
expint✓Unix 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.
itemsobject[]Several plans in one checkout, e.g. a family. Use instead of plan - see Family checkouts.
items[].planUUID string✓Plan for this line. Must be active and belong to mid.
items[].refstringYour id for this line (e.g. the child). Echoed back per subscription. ≤200 chars.
items[].labelstringShown to the payer beside the line, e.g. the child’s name. ≤60 chars.

For an enrolment, send exactly one of plan or items.

{
"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
}

To enrol several people in one checkout - siblings, or one person on several plans - replace plan with items. The payer sees every line with its label and price, agrees to one total, and pays once.

{
"akid": "9b5d4d80-0e1a-4b3a-9a4f-2b1c6c8a9d0e",
"mid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"items": [
{ "plan": "b6a8a5b8-7b3c-4d1e-9c2a-1f9e8d7c6b5a", "ref": "child_mia", "label": "Mia" },
{ "plan": "b6a8a5b8-7b3c-4d1e-9c2a-1f9e8d7c6b5a", "ref": "child_leo", "label": "Leo" },
{ "plan": "c1d2e3f4-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "ref": "child_mia_swim", "label": "Mia - swimming" }
],
"ref": "family_42",
"contact": { "fn": "Jane", "ln": "Smith", "em": "jane@example.com" },
"ret": "https://your-app.example.com/subscribed?family=42",
"evts": ["checkout.completed", "checkout.failed"],
"nonce": "5e8d2c1b9a4f7e3d6c0b8a2f1e9d4c7b",
"exp": 1769472000
}

How it behaves:

  • All or nothing. Every line becomes its own subscription on the payer’s membership, created together. If any line can’t be enrolled, none are.
  • Siblings can share a plan. Two lines may use the same plan as long as their ref differs. The same plan with the same ref twice is rejected.
  • One setup-fee charge. Setup fees across the lines are added up and taken as a single charge, so the payer sees one debit.
  • Each line keeps its own terms. Price, billing frequency and trial come from each line’s plan.
  • Already enrolled? If any line matches a subscription the member already holds live (same plan, same ref), the whole checkout is refused and nothing is charged. checkout.failed carries the existingSubscriptionId.
  • Limits. 1-10 lines. A link with both plan and items, an empty or oversized items, or an over-long ref or label is rejected with 400. A line whose plan is missing, inactive or another merchant’s is rejected with 404.

checkout.completed lists every subscription created, in the order you sent the lines, each with its ref - so you can match subscriptions back to children without relying on order.

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.