Everything we learned shipping production escrow with storefront builders: redirect URLs, embed checkout, webhooks, fulfillment sync, phone normalization, and how to record payments correctly.
Mint a checkout on your server with a secret API key, then open it in the buyer's browser — embedded modal or full-page redirect. Pass redirect URLs so buyers return to your storefront after pay and release.
Step 1
Call the Partner API when the buyer chooses protected payment. Persist transactionId on your order row. Never expose the API key to the browser.
import { TrustLayerClient } from '@trust-layer/sdk';
const tl = new TrustLayerClient({
apiKey: process.env.TRUSTLAYER_API_KEY, // server-side only — never in the browser
baseUrl: 'https://api.trustlayer.africa',
});
const { token, checkoutUrl, transactionId } = await tl.createCheckout({
sellerId: 'seller-uuid',
buyerPhone: '+2348012345678', // E.164 — normalize before this call
amountKobo: 250_000_00, // ₦250,000.00
itemTitle: 'Order #1042',
returnUrl: 'https://yourstore.com/orders/verify?checkoutId=abc',
cancelUrl: 'https://yourstore.com/checkout',
completeUrl: 'https://yourstore.com/orders/1042/thanks',
});
// → pass `token` to the browser; persist `transactionId` on your order rowStep 2
Load checkout.js and open the token in a modal, or redirect to checkoutUrl. Listen for trustlayer:paid and navigate to your returnUrl verify page.
<script src="https://trustlayer.africa/embed/checkout.js"></script>
<button
data-trustlayer-token="THE_TOKEN"
data-trustlayer-base="https://trustlayer.africa"
>
Pay with protection
</button>
<script>
TrustLayerCheckout.open({
token: 'THE_TOKEN',
baseUrl: 'https://trustlayer.africa', // optional — defaults to script origin
onEvent: (e) => {
if (e.type === 'trustlayer:paid') {
window.location.href = '/orders/verify?checkoutId=…';
}
},
onClose: () => console.log('modal closed'),
});
</script>All Partner API calls authenticate with your secret key. Send it as x-api-key (recommended) or Authorization: Bearer. Keys are hashed at rest and scoped to least privilege.
| Scope | Use |
|---|---|
checkout:create | Mint checkouts; dispatch and deliver partner-originated escrows |
reputation:read | Look up seller trust profiles (metered) |
seller:create | Provision Trust Layer sellers for merchants on your platform |
Server-side only
Base URL: https://api.trustlayer.africa. Wrapped by @trust-layer/sdk.
| Method | Path | Scope | Summary |
|---|---|---|---|
POST | /partner/sellers | seller:create | Provision a seller (idempotent on externalMerchantId) |
POST | /partner/checkouts | checkout:create | Mint a protected checkout; optional return/cancel/complete URLs |
GET | /partner/escrows/:transactionId | checkout:create | Read escrow state for a partner-originated transaction |
POST | /partner/escrows/:transactionId/dispatch | checkout:create | Mark shipped (FUNDED → IN_DELIVERY) for escrows you originated |
POST | /partner/escrows/:transactionId/deliver | checkout:create | Mark delivered; opens buyer inspection window |
GET | /partner/reputation/:key | reputation:read | RaaS lookup by seller handle or id (risk band included) |
POST | /partner/reputation/batch | reputation:read | Batch reputation lookup (1–50 keys) |
GET | /partner/sellers/:sellerId/reputation | reputation:read | Detailed score breakdown for one seller id |
GET | /partner/usage | (key only) | Originated checkout + reputation lookup counts |
Pass returnUrl, cancelUrl, and completeUrl when minting a checkout. Trust Layer validates each origin against your partner allowlist before storing them on the escrow row.
returnUrlAfter pay (FUNDED)
Send buyers here to sync your order — e.g. a verify-trust-payment page that polls until funded.
cancelUrlBefore pay (CREATED)
Shown while checkout is unpaid. Use your cart or delivery page so buyers can fix details and retry.
completeUrlAfter release (RELEASED)
Post-settlement thank-you page. Hosted checkout auto-redirects here after buyer confirms delivery.
// Three URLs — three moments in the buyer journey
returnUrl // After pay (FUNDED): sync your order, show "payment received"
cancelUrl // Before pay (CREATED): buyer abandoned hosted checkout
completeUrl // After release (RELEASED/REFUNDED): order fully settled
// Hosted checkout picks:
// active checkout → returnUrl (+ cancelUrl while CREATED)
// settled checkout → completeUrl (falls back to returnUrl if omitted)
await tl.createCheckout({
/* … */
returnUrl: `${origin}/verify?checkoutId=${checkoutId}`,
cancelUrl: `${origin}/checkout`,
completeUrl: `${origin}/orders/${orderId}/thanks`,
});Allowlist
Buyer-facing labels
Mint fresh checkouts
checkout.js opens /pay/{token}?embed=1 in a modal. Use checkoutUrl for a full-page experience or when embed is blocked (CSP, in-app browsers, strict third-party cookie policies).
| Approach | When to use |
|---|---|
| Embed (recommended) | Storefronts and dashboards where buyers should stay on your domain. Listen for trustlayer:paid then navigate to returnUrl. |
| Full redirect | Fallback when embed fails, WhatsApp in-app browser, or you prefer a hosted-only flow. Trust Layer shows partner return CTAs on the checkout page. |
| Embed + redirect fallback | Production pattern: try TrustLayerCheckout.open(); on script error or timeout, window.location.assign(checkoutUrl). |
Options: token (required), baseUrl, onEvent, onClose. Declarative buttons use data-trustlayer-token and optional data-trustlayer-base.
The hosted checkout posts these to window.parent while running inside your iframe. Your onEvent callback receives { type, token, state }.
| Event | When |
|---|---|
trustlayer:state | Every escrow state change while the iframe is open |
trustlayer:paid | Escrow reached FUNDED (buyer paid into escrow) |
trustlayer:released | Escrow reached RELEASED (buyer confirmed delivery) |
trustlayer:refunded | Escrow reached REFUNDED |
trustlayer:close | Embedded checkout signals the host may close the modal (after FUNDED or RELEASED) |
Do not rely on close alone
Configure webhookUrl (and webhookSecret) on your partner record. Deliveries are queued with retries; verify X-TrustLayer-Signature when a secret is set.
| Event | When |
|---|---|
checkout.created | Partner API minted a checkout |
checkout.funded | Buyer paid; funds locked in escrow |
escrow.dispatched | Partner dispatch API or seller marked shipped |
escrow.delivered | Partner deliver API or seller marked delivered |
checkout.released | Buyer confirmed delivery; seller wallet credited |
checkout.refunded | Escrow refunded to buyer (dispute or admin) |
checkout.cancelled | Unfunded escrow cancelled (seller, partner, or link expiry cron) |
dispute.opened | Buyer opened a dispute; funds frozen |
dispute.resolved | Admin resolved dispute (RELEASE or REFUND outcome in payload) |
import { createHmac, timingSafeEqual } from 'crypto';
function verifyTrustLayerWebhook(rawBody: string, signature: string | undefined, secret: string) {
if (!signature) return false;
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
try {
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
} catch {
return false;
}
}
// POST body shape:
// { id, event: 'checkout.funded', createdAt, data: { transactionId, amountKobo, … } }Not webhooked today
When merchants manage orders in your app (not Trust Layer), call dispatch and deliver so buyers see the correct checkout stepper and inspection window opens.
| State | Your action |
|---|---|
CREATED | Wait for buyer pay; show cancelUrl if they abandon |
FUNDED | Record sale locally; ship goods; call dispatch API |
IN_DELIVERY | In transit; call deliver when buyer receives |
INSPECTION | Buyer confirms or disputes on Trust Layer checkout |
RELEASED | Escrow complete; redirect to completeUrl |
REFUNDED | Funds returned to buyer; close order on your side |
DISPUTED | Listen for dispute.opened; pause fulfillment until dispute.resolved |
REFUNDED | Listen for checkout.refunded; close order and restore stock if needed |
// When your merchant marks an order shipped in YOUR portal:
await fetch(`${TRUSTLAYER_API}/partner/escrows/${transactionId}/dispatch`, {
method: 'POST',
headers: { 'x-api-key': TRUSTLAYER_API_KEY },
});
// When delivered (opens buyer inspection / confirm-delivery on Trust Layer):
await fetch(`${TRUSTLAYER_API}/partner/escrows/${transactionId}/deliver`, {
method: 'POST',
headers: { 'x-api-key': TRUSTLAYER_API_KEY },
});
// Buyer still confirms delivery on Trust Layer (OTP) unless auto-release fires.Buyer release still happens on Trust Layer
buyerPhone must be Nigerian E.164 (+234XXXXXXXXXX). Buyers verify via SMS OTP on the hosted checkout before paying — even in embed mode.
// Accept 080… or +234… from your form; always send +234XXXXXXXXXX to Trust Layer
function normalizeNgPhoneE164(input: string): string | null {
const digits = input.replace(/\D/g, '');
if (digits.length === 11 && digits.startsWith('0')) return `+234${digits.slice(1)}`;
if (digits.length === 13 && digits.startsWith('234')) return `+${digits}`;
if (input.startsWith('+234') && input.length === 14) return input;
return null;
}OTP on every new session
Seller phone used as buyer phone
Pilot / staging
Partners often mirror Trust Layer state in their own ledger. These rules prevent the most common accounting mismatches.
| Moment | Trust Layer | Your platform |
|---|---|---|
| checkout.funded / trustlayer:paid | Funds in escrow hold | Mark order paid; type as escrow/protected — not wallet-settled |
| Merchant ships | IN_DELIVERY → INSPECTION | Update fulfillment status; call dispatch + deliver APIs |
| checkout.released | Seller wallet credited | Order complete; payout is on Trust Layer — your Money page may show metadata only |
| Reused checkout row | Same transactionId updated | Update paidAt / updatedAt — stale createdAt from an earlier card attempt misleads reports |
Show tier and trust score on listing pages with a signed iframe. Unsigned loads still work for public profiles but signed badges prevent tampering.
<!-- Signed badge iframe — tier + score for a seller handle -->
<iframe
src="https://trustlayer.africa/embed/badge/adas-thrift?t=ISSUED_AT_MS&sig=HMAC_HEX"
width="320"
height="88"
style="border:0;border-radius:12px"
loading="lazy"
title="Trust Layer seller badge"
></iframe>
// Sign server-side with your badge secret (30-day TTL). See badge-sign in @trust-layer/shared.Requires reputation:read scope. Lookups are metered. riskBand (LOW / MEDIUM / HIGH) is included for underwriting-style decisions.
const rep = await tl.getReputation('oge-ventures');
// → { score: 588, tier: 'NEW',
// riskBand: 'LOW', completedSales: 0, … }
const batch = await tl.getReputationBatch(['oge-ventures', 'seller-2']);Risk bands
Each profile resolves to LOW, MEDIUM, or HIGH — act without interpreting raw scores.
Batch up to 50
Score a catalogue or search results page in one round trip.
Handle or id
Use public store handles where available; fall back to Trust Layer seller id.
Copy patterns that survived production traffic — storefront builders, WhatsApp bots, reputation-only.
Storefront builder
Mint checkout at cart pay → embed with redirect fallback → verify page polls until funded → merchant portal calls dispatch/deliver → buyer releases on Trust Layer → completeUrl.
WhatsApp / DM bot
Mint checkout server-side when buyer confirms → send checkoutUrl link (full redirect works best in in-app browsers) → webhook checkout.funded to notify merchant.
Reputation-only
No checkout scope needed. Batch-getReputation on listing pages; badge iframe on seller profiles. Metering applies per lookup.
Marketplace onboarding
POST /partner/sellers when a merchant signs up (idempotent on your merchant id). Store returned sellerId for checkout minting.
// Storefront pattern (production-tested):
// 1. Mint checkout server-side with all three redirect URLs
// 2. Try TrustLayerCheckout.open({ token, onEvent }) first
// 3. On trustlayer:paid → navigate to returnUrl (verify page)
// 4. Verify page polls YOUR backend until trustLayerFunded === true
// 5. On merchant ship/fulfill → call partner dispatch + deliver APIs
// 6. Buyer releases on Trust Layer → completeUrl + checkout.released webhook
// Always keep full-page checkoutUrl as fallback when embed is blocked.Errors we see most often when partners first wire up protected checkout.
400 — returnUrl origin is not allowed
Register your storefront origin on the partner record (allowedRedirectOrigins). Paths and query strings are fine; only the origin is checked.
400 — Phone must be a valid Nigerian E.164 number
Send buyerPhone as +234XXXXXXXXXX (13 chars). Normalize local 080… numbers server-side before calling createCheckout.
Embed opens but buyer stays on OTP screen after verifying
Buyer must complete OTP on Trust Layer. If seller phone equals buyer phone, the session still works — ensure your onPaid handler navigates to returnUrl, not only closes the modal.
Webhook checkout.funded never arrives
Configure webhookUrl on your partner record. Events are best-effort with retries; also handle trustlayer:paid in the embed or poll your verify page.
Reusing the same cart checkoutId shows an old payment date
One checkoutId maps to one transaction row. When Trust Layer funds a reused checkout, update paidAt/updatedAt locally — do not rely on createdAt from an earlier card attempt.
403 on dispatch/deliver
Only escrows originated by your partner key (originatorPartnerId) can be updated via the partner API.
Embed script blocked or modal empty
Fall back to checkoutUrl full-page redirect. Pass baseUrl when testing against staging.
Try it live
Run real requests against checkout, escrow, and reputation endpoints. Mint a checkout, copy cURL for your backend, open the embed modal, and watch client events — all in one place.
Requires a partner test key — email partners@trustlayer.africa
We help partners through first checkout, webhook verification, and storefront redirect setup.
partners@trustlayer.africa