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))>Payload schema
Section titled “Payload schema”The payload is a JSON object. Field names are short to keep the URL compact; don’t rename them.
| Field | Type | Required | Notes |
|---|---|---|---|
akid | UUID string | ✓ | Your API key id. Find it in Settings → API keys (column “Id”). |
mid | UUID string | ✓ | Your merchant id. Must match the API key’s merchant. |
plan | UUID string | ✓ | Plan id the user will be enrolled in. Must be active and belong to mid. |
ref | string | ✓ | Your internal id for this customer. Echoed in webhook payloads as externalRef. ≤200 chars. |
contact | object | Prefill for the contact step. All children optional. | |
contact.fn | string | First name. | |
contact.ln | string | Last name. | |
contact.em | string | Email. | |
contact.ph | string | Phone. | |
ret | URL string | ✓ | Where the user is redirected after success/cancel. ?status=ok or ?status=cancelled is appended. |
evts | string[] | ✓ | Webhook event types to deliver for this session. Typically ["checkout.completed", "checkout.failed"]. |
nonce | string | ✓ | Single-use random token (≥16 bytes, hex or base64url). DB-unique. Replay protection. |
exp | int | ✓ | Unix seconds expiry. Recommended: ≤30 minutes from now. Topiic rejects links past exp with 410 Gone. |
Example payload
Section titled “Example payload”{ "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}Algorithm
Section titled “Algorithm”- Serialise the payload to compact JSON (no whitespace). Field order doesn’t matter — Topiic deserialises by name.
- Base64url-encode the JSON bytes — the standard “URL-safe alphabet” base64 (
-and_instead of+and/) with trailing=padding stripped. Call thisd. - HMAC-SHA256 the ASCII bytes of
dusing the plaintext signing secret (tss_…) as the key. Base64url-encode the 32-byte digest. Call thiss. - Build the URL:
https://pay.topiic.com/c?d=<d>&s=<s>.
Reference implementations
Section titled “Reference implementations”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}`;}import base64, hmac, hashlib, json
def b64url(b: bytes) -> str: return base64.urlsafe_b64encode(b).decode().rstrip('=')
def build_deep_link(payload: dict, signing_secret: str, base: str = 'https://pay.topiic.com') -> str: d = b64url(json.dumps(payload, separators=(',', ':')).encode()) sig = hmac.new(signing_secret.encode(), d.encode(), hashlib.sha256).digest() s = b64url(sig) return f'{base}/c?d={d}&s={s}'<?phpfunction b64url(string $bytes): string { return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');}
function build_deep_link(array $payload, string $signingSecret, string $base = 'https://pay.topiic.com'): string { $d = b64url(json_encode($payload, JSON_UNESCAPED_SLASHES)); $sig = hash_hmac('sha256', $d, $signingSecret, true); $s = b64url($sig); return "{$base}/c?d={$d}&s={$s}";}package topiic
import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "fmt")
func b64url(b []byte) string { return base64.RawURLEncoding.EncodeToString(b)}
func BuildDeepLink(payload any, signingSecret, base string) (string, error) { j, err := json.Marshal(payload) if err != nil { return "", err } d := b64url(j) mac := hmac.New(sha256.New, []byte(signingSecret)) mac.Write([]byte(d)) s := b64url(mac.Sum(nil)) return fmt.Sprintf("%s/c?d=%s&s=%s", base, d, s), nil}Error responses from resolve
Section titled “Error responses from resolve”| HTTP status | Meaning | What to do |
|---|---|---|
| 200 | Link resolved; session JWT issued. | (Topiic handles this — your server isn’t called.) |
| 400 | Missing d or s. | Bug in your URL builder. |
| 401 | Invalid signature, or mid doesn’t match akid. | Re-check that you signed the exact bytes of d with the right secret. |
| 404 | API 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. |
| 409 | Nonce already used. | Generate a fresh nonce per link. |
| 410 | Link expired (exp in the past). | Reissue with a future exp. |
The user sees a friendly Topiic-branded error page; your server is not notified.
Operational tips
Section titled “Operational tips”- Generate links on-demand, not in advance. If you pre-generate hundreds with the same
exp, they all expire at the same time. - Pin
expto 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
refalongside 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.