Skip to content

Worked example

A full end-to-end hosted-checkout flow with realistic mock data - from "Subscribe" click to "subscribed" webhook.

This page walks through the entire hosted-checkout flow for a fictional integration, using realistic mock data. It’s the worked example to pair with the Quickstart — every value below is fake but plausible. Copy it, swap in your own IDs, and you have a runnable integration.

GymOps is a SaaS that runs class scheduling for boutique gyms. One of their customers, Sweat & Soul Pty Ltd, uses Topiic for membership billing. Sweat & Soul minted an API key in their Topiic portal and gave it to GymOps so GymOps can hand new members straight into Topiic’s hosted checkout.

A prospect — Maya Tan — has been browsing class packs on GymOps’ site and just clicked Subscribe to Premium Monthly ($49.50/mo).

GymOps’ server has these values in its secret store, namespaced per Topiic merchant:

# Sweat & Soul → Topiic
TOPIIC_MERCHANT_ID=e7d2f1a8-9c4b-4d62-8a3f-1b5c7e9d0f24
TOPIIC_API_KEY_ID=9b5d4d80-0e1a-4b3a-9a4f-2b1c6c8a9d0e
TOPIIC_API_KEY=tpk_S4Lj9xQpW2vRkT8mB3nFhYcDgZuE6aH1
TOPIIC_SIGNING_SECRET=tss_kZ4xQ9pYHmJ2vRkT8nFhYcDgZuEoA6vB
# GymOps internals
INTERNAL_CUSTOMER_ID=gym_member_8821
PREMIUM_MONTHLY_PLAN_ID=b6a8a5b8-7b3c-4d1e-9c2a-1f9e8d7c6b5a

Maya clicks Subscribe on GymOps’ web app. The form posts to GymOps’ own backend:

POST /signup/start HTTP/1.1
Host: app.gymops.example
Content-Type: application/json
Cookie: gymops_session=…
{
"plan": "premium_monthly",
"firstName": "Maya",
"lastName": "Tan",
"email": "maya@example.com",
"phone": "+61 422 138 904"
}
Section titled “Step 2 — GymOps builds the signed deep link”

GymOps’ handler builds a Topiic payload, signs it, and returns a 303 See Other so the browser is redirected to Topiic.

app/signup/start.ts
import crypto from 'node:crypto';
export async function startSignup(req, res) {
const customer = await db.customers.findById(req.session.userId);
const payload = {
akid: process.env.TOPIIC_API_KEY_ID,
mid: process.env.TOPIIC_MERCHANT_ID,
plan: process.env.PREMIUM_MONTHLY_PLAN_ID,
ref: customer.id, // "gym_member_8821"
contact: {
fn: req.body.firstName, // "Maya"
ln: req.body.lastName, // "Tan"
em: req.body.email, // "maya@example.com"
ph: req.body.phone // "+61 422 138 904"
},
ret: `https://app.gymops.example/signup/return?cust=${customer.id}`,
evts: ['checkout.completed', 'checkout.failed'],
nonce: crypto.randomBytes(16).toString('hex'),
exp: Math.floor(Date.now() / 1000) + 30 * 60 // 30-minute window
};
const d = b64url(Buffer.from(JSON.stringify(payload)));
const s = b64url(
crypto.createHmac('sha256', process.env.TOPIIC_SIGNING_SECRET).update(d).digest()
);
res.redirect(303, `https://pay.topiic.com/c?d=${d}&s=${s}`);
}
function b64url(b) {
return b.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

Concretely, the payload above serialises to:

{"akid":"9b5d4d80-0e1a-4b3a-9a4f-2b1c6c8a9d0e","mid":"e7d2f1a8-9c4b-4d62-8a3f-1b5c7e9d0f24","plan":"b6a8a5b8-7b3c-4d1e-9c2a-1f9e8d7c6b5a","ref":"gym_member_8821","contact":{"fn":"Maya","ln":"Tan","em":"maya@example.com","ph":"+61 422 138 904"},"ret":"https://app.gymops.example/signup/return?cust=gym_member_8821","evts":["checkout.completed","checkout.failed"],"nonce":"7c2d4f8a1b3e9c6d5a8f2b7e4c1d9a3b","exp":1769472000}

After base64url-encoding (d) and HMAC-signing with the signing secret (s), the redirect URL looks like:

https://pay.topiic.com/c?d=eyJha2lkIjoiOWI1ZDRkODAtMGUxYS00YjNhLTlhNGYtMmIxYzZjOGE5ZDBlIiwibWlkIjoiZTdkMmYxYTgtOWM0Yi00ZDYyLThhM2YtMWI1YzdlOWQwZjI0IiwicGxhbiI6ImI2YThhNWI4LTdiM2MtNGQxZS05YzJhLTFmOWU4ZDdjNmI1YSIsInJlZiI6Imd5bV9tZW1iZXJfODgyMSIsImNvbnRhY3QiOnsiZm4iOiJNYXlhIiwibG4iOiJUYW4iLCJlbSI6Im1heWFAZXhhbXBsZS5jb20iLCJwaCI6Iis2MSA0MjIgMTM4IDkwNCJ9LCJyZXQiOiJodHRwczovL2FwcC5neW1vcHMuZXhhbXBsZS9zaWdudXAvcmV0dXJuP2N1c3Q9Z3ltX21lbWJlcl84ODIxIiwiZXZ0cyI6WyJjaGVja291dC5jb21wbGV0ZWQiLCJjaGVja291dC5mYWlsZWQiXSwibm9uY2UiOiI3YzJkNGY4YTFiM2U5YzZkNWE4ZjJiN2U0YzFkOWEzYiIsImV4cCI6MTc2OTQ3MjAwMH0&s=tH7vK2pYHmJqW8nFhYcDgZuEoA6vB9xQpW2vRkT8mB3

(Lines wrapped for display — the real URL is one line.)

The browser hits https://pay.topiic.com/c?d=…&s=…. Topiic verifies the HMAC against the signing secret, confirms the merchant and plan are valid, checks the exp hasn’t passed and the nonce hasn’t been used before, and serves the checkout page. If anything fails, Maya sees a friendly error page and GymOps is not notified.

Step 4 — User confirms contact and captures card

Section titled “Step 4 — User confirms contact and captures card”

Maya sees the Topiic-branded checkout page with her contact details prefilled from the contact payload. She confirms (or edits) and continues to the payment step. She enters her card details in the secure card-capture form — the card number is tokenised directly by the payment gateway and never touches Topiic’s servers.

Topiic creates everything Maya needs for an active membership:

  1. Member — created (or matched on email if she already exists for this merchant). Result: memberId = 1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d.
  2. Payment method — the card token stored against the Member. Result: paymentMethodId = 2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e.
  3. Subscription — Maya enrolled in planId = b6a8a5b8-…, billed against the new payment method. Result: subscriptionId = 3c4d5e6f-7a8b-9c0d-1e2f-3a4b5c6d7e8f.
  4. Webhook queued for delivery to GymOps’ configured URL.

The checkout page then redirects Maya’s browser to returnUrl?status=ok.

Within a couple of seconds Topiic POSTs the signed event to GymOps:

POST /webhooks/topiic HTTP/1.1
Host: app.gymops.example
Content-Type: application/json
User-Agent: Topiic-Webhooks/1.0
Topiic-Event-Id: 4f8c2a14-7b3d-4e9c-8d6f-1a2b3c4d5e6f
Topiic-Idempotency-Key: 4f8c2a14-7b3d-4e9c-8d6f-1a2b3c4d5e6f
Topiic-Signature: t=1769472312,v1=8f2a1c4b9d6e3a7f0c5b8a1d2e4f6c9b7a3d5e8f0c1a4b7d9e2c6a8b3d5f7e9c
{
"id": "4f8c2a14-7b3d-4e9c-8d6f-1a2b3c4d5e6f",
"type": "checkout.completed",
"createdAt": "2026-05-27T11:05:12.123Z",
"data": {
"sessionId": "7e6d5c4b-3a2f-1e0d-9c8b-7a6f5e4d3c2b",
"externalRef": "gym_member_8821",
"memberId": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"paymentMethodId": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e",
"subscriptionId": "3c4d5e6f-7a8b-9c0d-1e2f-3a4b5c6d7e8f",
"planId": "b6a8a5b8-7b3c-4d1e-9c2a-1f9e8d7c6b5a"
}
}

GymOps’ webhook handler verifies the signature, dedupes on event id, and persists the link between the customer and Topiic’s IDs — all in one DB transaction so retries are safe.

import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.TOPIIC_SIGNING_SECRET;
app.post('/webhooks/topiic',
express.raw({ type: 'application/json' }), // raw bytes - HMAC needs the exact body
async (req, res) => {
if (!verify(SECRET, req.body, req.header('Topiic-Signature'))) {
return res.status(401).send('bad signature');
}
const event = JSON.parse(req.body.toString('utf8'));
await db.transaction(async (tx) => {
// Idempotency: insert-or-noop on event id.
const { rowCount } = await tx.query(
'INSERT INTO topiic_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING',
[event.id]
);
if (rowCount === 0) return; // duplicate delivery, silent ack
if (event.type === 'checkout.completed') {
const d = event.data;
await tx.query(
`UPDATE customers
SET topiic_member_id = $1,
topiic_subscription_id = $2,
topiic_payment_method_id = $3,
topiic_plan_id = $4,
status = 'active',
subscribed_at = NOW()
WHERE id = $5`,
[d.memberId, d.subscriptionId, d.paymentMethodId, d.planId, d.externalRef]
);
} else if (event.type === 'checkout.failed') {
await tx.query(
`UPDATE customers
SET status = 'signup_failed',
last_failure_reason = $1,
last_failed_at = NOW()
WHERE id = $2`,
[event.data.failureReason, event.data.externalRef]
);
}
// Unknown event types: silently 200.
});
res.status(200).end();
}
);
function verify(secret, raw, header) {
if (!header) return false;
const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=').map((s) => s.trim())));
const t = Number(parts.t);
const v1 = parts.v1;
if (!t || !v1) return false;
if (Math.abs(Date.now() / 1000 - t) > 300) return false; // 5-minute freshness window
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${raw.toString('utf8')}`)
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(v1, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Topiic’s POST returns 200 OK within ~40ms. The delivery row transitions to Succeeded and no retries are scheduled.

Maya’s browser arrives at:

https://app.gymops.example/signup/return?cust=gym_member_8821&status=ok

GymOps’ return handler looks up cust=gym_member_8821 in its own database. The webhook from Step 6 has already landed, so the customer row shows status='active' and a subscribed_at timestamp — the page renders straight to ”🎉 Welcome to Sweat & Soul Premium”.

If GymOps’ database showed the customer as still pending (the webhook lost the race with the redirect), the return handler would render a "Activating your membership…" interstitial that polls /signup/status?cust=gym_member_8821 every 2 seconds for up to 30 seconds. Either way, the webhook is the source of truth, not the ?status=ok query parameter.

SystemNew rows
TopiicMember(1a2b…), PaymentMethod(2b3c…), Subscription(3c4d…), CheckoutSession(7e6d…, Completed), WebhookEvent(4f8c…), WebhookDelivery(…, Succeeded)
GymOpscustomers.topiic_member_id = 1a2b…, topiic_subscription_id = 3c4d…, status = active, subscribed_at = 2026-05-27T11:05:14Z; topiic_events(4f8c…)

Maya’s card will be debited on the plan’s billing cadence. Subsequent lifecycle events — successful charges, failed charges, refunds, pauses, cancellations — are each delivered as their own webhook so GymOps can keep its customer record in sync. See the full event catalog.

Ask your Topiic contact for a sandbox merchant. In sandbox mode the checkout flow runs end-to-end without touching real money — Topiic returns synthetic card tokens that the gateway recognises, so you can exercise every webhook your handler needs to deal with.

Point your webhook URL at webhook.site while you’re iterating — it’s the fastest way to inspect the signed payload Topiic produces before you wire up your real handler.

Where it can go wrong — and what you’d see

Section titled “Where it can go wrong — and what you’d see”
SymptomLikely causeResolution
Browser hits pay.topiic.com/c and shows “This checkout link is not valid.”Tampered s, wrong signing secret, or the API key id in akid doesn’t exist.Confirm GymOps is signing with the secret that matches the API key id in the payload.
Browser shows “This checkout link has expired.”exp in the past.Reissue with a fresh exp (recommended ≤30 min).
Browser shows “This checkout link has already been used.”A nonce collision — same nonce resolved before.Ensure each link gets a fresh crypto.randomBytes(16) nonce.
Maya completes checkout but GymOps never marks her active.Either the webhook is bouncing on signature verification (raw-body issue), or GymOps’ handler is failing silently.Look at Settings → API keys → Deliveries in the Topiic portal: it shows every attempt with response status + body.
GymOps receives the webhook twice.A previous attempt was slow; Topiic retried.Expected behaviour — your topiic_events table dedupes on event id. The ON CONFLICT DO NOTHING clause above is doing exactly this.