This guide takes a server-side integration from an issued key to a confirmed and validated ticket, then makes the webhook receiver safe for production. It requires no Zentry internal routes or database knowledge. Keep the public API v1 reference beside it for field limits and complete response schemas.
What Zentry and the tenant each own
Zentry is merchant of record. A company prepays its FLOAT balance; checkout spends the full
ticket gross from that balance and immediately credits the configured commission into a separate
EARNINGS balance. Zentry owns inventory allocation, fulfilment, wallet ledger continuity, and
ticket validation. The company owns its storefront, buyer relationship, API-key custody, float
funding arrangements, webhook processing, and support workflow; only Zentry's trusted PLATFORM
operation can apply a verified funding credit.
The company supplies buyer details for transactional fulfilment. Checkout links the ticket to an existing Zentry user or creates a dormant passwordless record. A tenant-triggered ticket email is branded for the company and sent through Zentry's verified sender. Zentry does not initiate marketing or treat that dormant buyer as Zentry's customer.
Prerequisites
Coordinate these prerequisites before running checkout. The company administrator confirms company, verification, branding, float, key, and test-inbox readiness in the Business Suite. Zentry onboarding or platform finance confirms the commission-rate schedule:
- The company status is active and its KYB case has a current approval. A legacy-review grace state may keep an existing integration operational during rollout, but it is not an approval. Complete requested changes and obtain approval before relying on production checkout.
- Branding has been reviewed. Every company starts with safe defaults, and checkout snapshots the current brand onto the order and ticket.
- Zentry onboarding or platform finance has scheduled an explicit commission rate with its unit and effective time. Company credentials cannot schedule it, and Zentry never falls back to a platform default.
- The prepaid float has been credited from a verified provider settlement. Tenants cannot mint their own float through the public API.
- An API key has the exact grants needed by this integration.
- A test inbox has consented to the transactional fulfilment email used in acceptance testing.
The signed-in company administrator completes verification at
/business/{tenantId}/verification on the supplied web-app origin. The user session needs the
exact COMPANY:kyb:read, COMPANY:kyb:manage, and COMPANY:kyb:submit grants; none of these grants
can be put on a public API key. Assign an owner for renewal, monitor approvalExpiresAt, and renew
before it passes. Phase 1 emits no KYB-expiry webhook or public event.
There is no public v1 KYB endpoint or verification preflight. A successful event read, inventory
read, or pricing quote does not prove checkout readiness. Checkout is the authoritative boundary;
409 COMPANY_VERIFICATION_REQUIRED reveals no private review reason. Keep reads and ticket
validation available, show a temporary storefront-unavailable state for new purchases, and notify
a company administrator to use the Verification page. Retry checkout only after that page shows
transaction access from a current approval or the explicit rollout grace state.
For the full acceptance path, issue one key with:
[
"COMPANY:event:read",
"COMPANY:event:manage",
"COMPANY:inventory:read",
"COMPANY:inventory:manage",
"COMPANY:checkout:create",
"COMPANY:order:read",
"COMPANY:ticket:validate"
]
Production integrations should use narrower keys. Public keys cannot administer companies, members, keys, webhooks, branding, balances, commission, or settlements.
Environments and tools
Use the API origin supplied during onboarding. This repository does not declare a production or sandbox hostname, so do not guess one from the web application's hostname.
# Supplied non-production origin:
export ZENTRY_PUBLIC_API_BASE_URL='https://<supplied-api-origin>/api/public/v1'
# Local server on the default port:
# export ZENTRY_PUBLIC_API_BASE_URL='http://localhost:4001/api/public/v1'
The base path is the same in every environment. A separately named sandbox hostname is not part of the v1 contract; onboarding must supply the intended non-production origin.
The walkthrough uses curl 7.76 or newer and jq 1.6 or newer. Keep secrets out of shell history:
read -r -s -p 'Zentry API key: ' ZENTRY_PUBLIC_API_KEY
export ZENTRY_PUBLIC_API_KEY
printf '\n'
export ZENTRY_INTEGRATION_RUN_ID='partner-launch-001'
export ZENTRY_TEST_BUYER_EMAIL='[email protected]'
ZENTRY_INTEGRATION_RUN_ID must contain only letters, numbers, ., _, :, or -. Use a new
value for a genuinely new acceptance order. Reuse the same value when retrying this walkthrough.
Confirm that you reached the intended deployment and that its public contract is published:
curl --fail-with-body --silent --show-error \
"$ZENTRY_PUBLIC_API_BASE_URL/openapi.json" \
| jq -e '.info.title == "Zentry Public API" and .info.version == "1"'
The interactive reference is at $ZENTRY_PUBLIC_API_BASE_URL/docs.
Call the API safely
Every request uses Bearer authentication. Do not send an X-API-Key header.
curl --fail-with-body --silent --show-error \
-H "Authorization: Bearer $ZENTRY_PUBLIC_API_KEY" \
-H 'Accept: application/json' \
"$ZENTRY_PUBLIC_API_BASE_URL/events?limit=1"
Attach an X-Request-Id from your own trace when available. The response echoes it in both the
header and meta.requestId. Log that request ID, HTTP status, and stable error code, but never log
the Authorization header or full buyer payload.
For a mutation marked idempotent, generate the key once at the business-action boundary, persist it with your local operation, and reuse it across network timeouts, process restarts, and API-key rotation. Do not generate a new key inside each HTTP retry.
Recommended retry policy:
- Retry a connection failure, timeout,
429, or5xxwith capped exponential backoff and jitter. - Retry an idempotent mutation only with the same body and
Idempotency-Key. - On an ambiguous timeout, query the resource or retry the identical idempotent mutation; never assume failure. Current key authority and KYB are checked before an idempotent checkout replay. A later verification-required response therefore does not prove that the timed-out original failed: restore eligibility, then retry the same body and key to recover its authoritative result.
- Re-quote after
409 INVENTORY_UNAVAILABLEorPRICING_UNAVAILABLE. - Stop and alert on
401,403,COMPANY_VERIFICATION_REQUIRED,COMMISSION_RATE_UNAVAILABLE,INSUFFICIENT_FLOAT, orCHECKOUT_UNAVAILABLE; those require configuration or funding, not a tight retry loop.
End-to-end API-only checkout
1. Create a published event
EVENT_RESPONSE="$({
curl --fail-with-body --silent --show-error \
-X POST "$ZENTRY_PUBLIC_API_BASE_URL/events" \
-H "Authorization: Bearer $ZENTRY_PUBLIC_API_KEY" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: smoke:$ZENTRY_INTEGRATION_RUN_ID:event" \
-H "X-Request-Id: smoke.$ZENTRY_INTEGRATION_RUN_ID.event" \
--data-binary @- <<JSON
{
"title": "Partner acceptance $ZENTRY_INTEGRATION_RUN_ID",
"description": "Public API v1 acceptance event",
"date": "2099-12-31",
"startsAt": "18:00",
"endsAt": "22:00",
"timezone": "Africa/Lagos",
"location": "Integration test venue",
"venue": "North hall",
"imageUrl": "https://example.com/zentry-public-api-smoke.jpg",
"organizerName": "Partner integration",
"status": "PUBLISHED"
}
JSON
})"
EVENT_ID="$(jq -er '.data.id' <<<"$EVENT_RESPONSE")"
printf 'event: %s\n' "$EVENT_ID"
Omit categoryId unless onboarding supplied an active category UUID. Public v1 intentionally has
no category catalogue, and category is optional.
Ticket categories are configured separately: add "ticketCategories": ["Table for 4"] when
creating or editing an event to add a custom admission category alongside the six presets.
Use the exact returned category name as tier in inventory, quote, and checkout requests.
Names must be 1–60 characters without surrounding whitespace or control characters, with at
most 50 categories per event. Editing an event preserves previously established category names.
2. Add credentialed inventory
INVENTORY_RESPONSE="$({
curl --fail-with-body --silent --show-error \
-X POST "$ZENTRY_PUBLIC_API_BASE_URL/events/$EVENT_ID/inventory" \
-H "Authorization: Bearer $ZENTRY_PUBLIC_API_KEY" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: smoke:$ZENTRY_INTEGRATION_RUN_ID:inventory" \
-H "X-Request-Id: smoke.$ZENTRY_INTEGRATION_RUN_ID.inventory" \
--data-binary @- <<'JSON'
{
"tier": "VIP",
"title": "Acceptance VIP admission",
"description": "Created by the public API acceptance path",
"unitPrice": "100.00",
"currency": "NGN",
"isFree": false,
"quantity": 4,
"status": "AVAILABLE"
}
JSON
})"
jq -e '.data.object == "inventory_tier" and .data.status == "AVAILABLE"' \
<<<"$INVENTORY_RESPONSE"
One inventory row is one scannable admission. Calling this operation with a new idempotency key adds another batch; it does not replace the four admissions.
3. Quote current inventory
QUOTE_RESPONSE="$({
jq -n --arg eventId "$EVENT_ID" \
'{eventId: $eventId, lines: [{tier: "VIP", quantity: 1}]}' \
| curl --fail-with-body --silent --show-error \
-X POST "$ZENTRY_PUBLIC_API_BASE_URL/pricing/quote" \
-H "Authorization: Bearer $ZENTRY_PUBLIC_API_KEY" \
-H 'Content-Type: application/json' \
-H "X-Request-Id: smoke.$ZENTRY_INTEGRATION_RUN_ID.quote" \
--data-binary @-
})"
EXPECTED_PRICE="$(jq -er '.data.lines[0].unitPrice.amount' <<<"$QUOTE_RESPONSE")"
CURRENCY="$(jq -er '.data.lines[0].unitPrice.currency' <<<"$QUOTE_RESPONSE")"
printf 'quote: %s %s\n' "$CURRENCY" "$EXPECTED_PRICE"
The quote is not a hold. Pass the exact quoted price to checkout and handle a stock or price race.
For a multi-tier quote, response lines use canonical lexicographic tier order rather than request
order. Find each line by tier; never attach a price by array index.
4. Create an immediate checkout
CHECKOUT_BODY="$({
jq -n \
--arg eventId "$EVENT_ID" \
--arg email "$ZENTRY_TEST_BUYER_EMAIL" \
--arg price "$EXPECTED_PRICE" \
--arg currency "$CURRENCY" \
'{
eventId: $eventId,
buyer: {
email: $email,
fullname: "API Acceptance Buyer",
phone: "+2348000000000"
},
lines: [{
tier: "VIP",
quantity: 1,
expectedUnitPrice: $price,
currency: $currency
}]
}'
})"
CHECKOUT_RESPONSE="$({
curl --fail-with-body --silent --show-error \
-X POST "$ZENTRY_PUBLIC_API_BASE_URL/checkouts" \
-H "Authorization: Bearer $ZENTRY_PUBLIC_API_KEY" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: smoke:$ZENTRY_INTEGRATION_RUN_ID:checkout" \
-H "X-Request-Id: smoke.$ZENTRY_INTEGRATION_RUN_ID.checkout" \
--data-binary "$CHECKOUT_BODY"
})"
ORDER_ID="$(jq -er '.data.id' <<<"$CHECKOUT_RESPONSE")"
ADMISSION_ID="$(jq -er '.data.lines[0].admissionId' <<<"$CHECKOUT_RESPONSE")"
jq -e \
'.data.status == "CONFIRMED"
and .data.gross.amount == .data.floatDebited.amount
and (.data.commission.rate.unit
| IN("BASIS_POINTS", "PERCENT", "FRACTION"))' \
<<<"$CHECKOUT_RESPONSE"
printf 'order: %s\nadmission: %s\n' "$ORDER_ID" "$ADMISSION_ID"
The response proves both balances independently. Do not calculate a rate by guessing from its
value; always inspect commission.rate.unit.
Prove the retry does not create a second order:
REPLAYED_CHECKOUT="$({
curl --fail-with-body --silent --show-error \
-X POST "$ZENTRY_PUBLIC_API_BASE_URL/checkouts" \
-H "Authorization: Bearer $ZENTRY_PUBLIC_API_KEY" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: smoke:$ZENTRY_INTEGRATION_RUN_ID:checkout" \
-H "X-Request-Id: smoke.$ZENTRY_INTEGRATION_RUN_ID.checkout-retry" \
--data-binary "$CHECKOUT_BODY"
})"
test "$(jq -er '.data.id' <<<"$REPLAYED_CHECKOUT")" = "$ORDER_ID"
5. Read the immutable order
curl --fail-with-body --silent --show-error \
-H "Authorization: Bearer $ZENTRY_PUBLIC_API_KEY" \
-H "X-Request-Id: smoke.$ZENTRY_INTEGRATION_RUN_ID.order" \
"$ZENTRY_PUBLIC_API_BASE_URL/orders/$ORDER_ID" \
| jq -e --arg orderId "$ORDER_ID" '.data.id == $orderId'
curl --fail-with-body --silent --show-error \
-G "$ZENTRY_PUBLIC_API_BASE_URL/orders" \
-H "Authorization: Bearer $ZENTRY_PUBLIC_API_KEY" \
--data-urlencode "eventId=$EVENT_ID" \
--data-urlencode 'status=CONFIRMED' \
--data-urlencode 'limit=25' \
| jq -e --arg orderId "$ORDER_ID" \
'any(.data[]; .id == $orderId) and (.meta.page.hasMore | type == "boolean")'
Persist the order and admission IDs in your system. Do not derive them from a ticket title, tier, buyer email, or list position.
6. Validate the admission
One operator action gets one idempotency key:
VALIDATION_RESPONSE="$({
curl --fail-with-body --silent --show-error \
-X POST \
"$ZENTRY_PUBLIC_API_BASE_URL/events/$EVENT_ID/admissions/$ADMISSION_ID/validate" \
-H "Authorization: Bearer $ZENTRY_PUBLIC_API_KEY" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: smoke:$ZENTRY_INTEGRATION_RUN_ID:admit" \
-H "X-Request-Id: smoke.$ZENTRY_INTEGRATION_RUN_ID.admit" \
--data-binary '{"location":"Acceptance gate"}'
})"
VALIDATION_ID="$(jq -er '.data.id' <<<"$VALIDATION_RESPONSE")"
jq -e \
--arg admissionId "$ADMISSION_ID" \
'.data.admissionId == $admissionId
and (.data.outcome == "ADMITTED" or .data.outcome == "ALREADY_VALIDATED")' \
<<<"$VALIDATION_RESPONSE"
Retrying with the same key returns that exact outcome. A second operator scan uses a new key and must report the original validation as already used:
SECOND_SCAN="$({
curl --fail-with-body --silent --show-error \
-X POST \
"$ZENTRY_PUBLIC_API_BASE_URL/events/$EVENT_ID/admissions/$ADMISSION_ID/validate" \
-H "Authorization: Bearer $ZENTRY_PUBLIC_API_KEY" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: smoke:$ZENTRY_INTEGRATION_RUN_ID:second-scan" \
-H "X-Request-Id: smoke.$ZENTRY_INTEGRATION_RUN_ID.second-scan" \
--data-binary '{"location":"Acceptance gate"}'
})"
jq -e --arg validationId "$VALIDATION_ID" \
'.data.outcome == "ALREADY_VALIDATED" and .data.id == $validationId' \
<<<"$SECOND_SCAN"
The first admission emits ticket.validated; the transport retry and second scan do not emit a
duplicate webhook.
Cursor loops
Never construct or decode a cursor. This shell loop demonstrates the complete termination rule:
CURSOR=''
while :; do
if [ -n "$CURSOR" ]; then
PAGE="$({
curl --fail-with-body --silent --show-error \
-G "$ZENTRY_PUBLIC_API_BASE_URL/orders" \
-H "Authorization: Bearer $ZENTRY_PUBLIC_API_KEY" \
--data-urlencode 'limit=100' \
--data-urlencode "cursor=$CURSOR"
})"
else
PAGE="$({
curl --fail-with-body --silent --show-error \
-G "$ZENTRY_PUBLIC_API_BASE_URL/orders" \
-H "Authorization: Bearer $ZENTRY_PUBLIC_API_KEY" \
--data-urlencode 'limit=100'
})"
fi
jq -c '.data[]' <<<"$PAGE"
jq -e '.meta.page.hasMore == false' >/dev/null <<<"$PAGE" && break
CURSOR="$(jq -er '.meta.page.nextCursor' <<<"$PAGE")"
done
Use a separate cursor stream for each filter set. If a cursor is invalid or is accidentally moved
between /events and /orders, start that listing again instead of attempting to repair it.
Put an embed behind a server transport
The browser embed is a view over four public v1 operations. It never receives an API key and never calls the public API origin directly. Your server owns a small authenticated proxy; the browser passes a fixed operation name and a validated payload to that proxy, and the proxy adds its Bearer key from a server-side secret store.
Use a dedicated embed key with only these grants:
["COMPANY:event:read", "COMPANY:inventory:read", "COMPANY:checkout:create"]
The four allowed transport operations are fixed in v1:
| Operation | Transport payload | Server-side public API call |
|---|---|---|
events.list | { limit?, cursor? } | GET /events with status=PUBLISHED forced by the proxy |
events.inventory | { eventId } | Prove PUBLISHED, read inventory, then prove PUBLISHED again before responding |
pricing.quote | { eventId, lines: [{ tier, quantity }] } | Prove the event is PUBLISHED, then POST /pricing/quote |
checkout.create | { eventId, buyer, lines: [{ tier, quantity, expectedUnitPrice, currency }], idempotencyKey } | Prove the event is PUBLISHED, then POST /checkouts; move the idempotency key |
Validate again at the proxy even though the SDK validates in the browser. Accept only the
operation-specific fields in this table and the exact public request constraints in the
v1 reference; never accept an upstream URL, path, method, header, or
tenant identifier from the browser. The event-list input has no status option: always send
status=PUBLISHED upstream. Before inventory, pricing, or checkout, retrieve the exact event with
the same tenant key and fail closed unless its current public status is PUBLISHED. Since the
API-only inventory read also supports draft management, repeat the event read after successful
inventory and discard that response if publication changed. Pricing and checkout enforce active
event state inside their own operation; a post-commit checkout check would create an ambiguous
retry after a real order. Authenticate the storefront session, enforce CSRF protection where
applicable, and rate-limit the proxy independently. In particular, do not let an anonymous caller
turn it into a general checkout relay.
On success, validate the upstream envelope and project it into the operation-specific browser
schema before writing the proxy HTTP response. Never forward the public response body verbatim.
Checkout exposes only id, object, eventId, status, gross, the five public branding fields
(brandName, logoUrl, primaryColor, accentColor, checkoutFooter), admission lines, and
confirmedAt. Buyer PII, float debits, commission/rate, FLOAT/EARNINGS snapshots, private branding,
and createdAt remain server-side. On failure, preserve the HTTP status but project the body to
{ error: { code, message }, meta: { requestId } }.
Use a separate full-public-order allowlist before constructing the smaller browser order. This server-side projector covers all four success operations and rejects unknown fields instead of silently trusting them:
// app/api/zentry-proxy/project-response.js
const UUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const MONEY = /^(?:0|[1-9]\d{0,11})(?:\.\d{1,2})?$/;
const REQUEST_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const ERROR_CODE = /^[A-Z][A-Z0-9_]{0,99}$/;
const CURSOR = /^[A-Za-z0-9_-]{1,512}$/;
const HEX = /^#[0-9A-F]{6}$/i;
const MAX_TICKET_CATEGORIES = 50;
const INVENTORY_STATES = new Set([
'AVAILABLE',
'PAUSED',
'SOLD_OUT',
'CANCELLED',
]);
const ORDER_STATES = new Set(['CONFIRMED', 'CANCELLED', 'REFUNDED']);
const RATE_UNITS = new Set(['BASIS_POINTS', 'PERCENT', 'FRACTION']);
function ticketCategory(value) {
if (
typeof value !== 'string' ||
value.length === 0 ||
value.length > 60 ||
value !== value.trim() ||
/\p{Cc}/u.test(value) ||
[...Object.getOwnPropertyNames(Object.prototype), 'prototype'].some(
(name) => name.toLowerCase() === value.toLowerCase(),
)
) {
throw new Error('Invalid ticket category');
}
return value;
}
function exactObject(value, fields, label, optional = []) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error(`Invalid ${label}`);
}
const unknown = Object.keys(value).find((field) => !fields.includes(field));
if (unknown) throw new Error(`Invalid ${label} field: ${unknown}`);
const missing = fields.find(
(field) =>
!optional.includes(field) &&
!Object.prototype.hasOwnProperty.call(value, field),
);
if (missing) throw new Error(`Missing ${label} field: ${missing}`);
return value;
}
function text(value, label, maximum) {
if (
typeof value !== 'string' ||
value.length === 0 ||
value.length > maximum ||
/[\u0000-\u001F\u007F]/.test(value)
) {
throw new Error(`Invalid ${label}`);
}
return value;
}
function nullableText(value, label, maximum) {
return value === null ? null : text(value, label, maximum);
}
function oneOf(value, allowed, label) {
if (typeof value !== 'string' || !allowed.has(value)) {
throw new Error(`Invalid ${label}`);
}
return value;
}
function uuid(value, label) {
if (typeof value !== 'string' || !UUID.test(value)) {
throw new Error(`Invalid ${label}`);
}
return value;
}
function integer(value, minimum, maximum, label) {
if (!Number.isInteger(value) || value < minimum || value > maximum) {
throw new Error(`Invalid ${label}`);
}
return value;
}
function timestamp(value, label) {
text(value, label, 40);
if (
!/^\d{4}-\d{2}-\d{2}T/.test(value) ||
!Number.isFinite(Date.parse(value))
) {
throw new Error(`Invalid ${label}`);
}
return value;
}
function httpsUrl(value, label) {
let parsed;
try {
parsed = new URL(value);
} catch {
throw new Error(`Invalid ${label}`);
}
if (
parsed.protocol !== 'https:' ||
parsed.username ||
parsed.password ||
parsed.hash
) {
throw new Error(`Invalid ${label}`);
}
return parsed.href;
}
function money(value) {
const input = exactObject(value, ['amount', 'currency'], 'money');
if (typeof input.amount !== 'string' || !MONEY.test(input.amount)) {
throw new Error('Invalid money amount');
}
return {
amount: input.amount,
currency: oneOf(input.currency, new Set(['NGN']), 'currency'),
};
}
function responseMeta(value, paged) {
const input = exactObject(
value,
paged ? ['requestId', 'page'] : ['requestId'],
'response metadata',
);
const requestId = text(input.requestId, 'request id', 128);
if (!REQUEST_ID.test(requestId)) throw new Error('Invalid request id');
if (!paged) return { requestId };
const page = exactObject(
input.page,
['hasMore', 'nextCursor'],
'page metadata',
);
if (typeof page.hasMore !== 'boolean')
throw new Error('Invalid page metadata');
const nextCursor =
page.nextCursor === null ? null : text(page.nextCursor, 'cursor', 512);
if (nextCursor !== null && !CURSOR.test(nextCursor))
throw new Error('Invalid cursor');
if (page.hasMore && nextCursor === null)
throw new Error('Invalid page metadata');
return { requestId, page: { hasMore: page.hasMore, nextCursor } };
}
function publicEvent(value) {
const input = exactObject(
value,
[
'id',
'object',
'title',
'description',
'status',
'schedule',
'location',
'imageUrl',
'organizerName',
'category',
'ticketCategories',
'createdAt',
'updatedAt',
],
'event',
);
const schedule = exactObject(
input.schedule,
['date', 'startsAt', 'endsAt', 'timezone'],
'event schedule',
);
const location = exactObject(
input.location,
['name', 'venue'],
'event location',
);
let category = null;
if (input.category !== null) {
const source = exactObject(
input.category,
['id', 'name', 'slug'],
'event category',
);
category = {
id: uuid(source.id, 'category id'),
name: text(source.name, 'category name', 200),
slug: text(source.slug, 'category slug', 200),
};
}
if (
!Array.isArray(input.ticketCategories) ||
input.ticketCategories.length > MAX_TICKET_CATEGORIES
) {
throw new Error('Invalid event ticket categories');
}
return {
id: uuid(input.id, 'event id'),
object: oneOf(input.object, new Set(['event']), 'event object'),
title: text(input.title, 'event title', 200),
description: nullableText(input.description, 'event description', 5000),
status: oneOf(input.status, new Set(['PUBLISHED']), 'event status'),
schedule: {
date: nullableText(schedule.date, 'event date', 10),
startsAt: nullableText(schedule.startsAt, 'event start time', 5),
endsAt: nullableText(schedule.endsAt, 'event end time', 5),
timezone: nullableText(schedule.timezone, 'event timezone', 100),
},
location: {
name: nullableText(location.name, 'event location', 500),
venue: nullableText(location.venue, 'event venue', 500),
},
imageUrl: httpsUrl(input.imageUrl, 'event image URL'),
organizerName: nullableText(input.organizerName, 'event organizer', 200),
category,
ticketCategories: input.ticketCategories.map(ticketCategory),
createdAt: timestamp(input.createdAt, 'event creation time'),
updatedAt: timestamp(input.updatedAt, 'event update time'),
};
}
function inventoryTier(value) {
const input = exactObject(
value,
[
'object',
'tier',
'status',
'availableQuantity',
'totalAdmissions',
'unitPrice',
],
'inventory',
);
return {
object: oneOf(
input.object,
new Set(['inventory_tier']),
'inventory object',
),
tier: ticketCategory(input.tier),
status: oneOf(input.status, INVENTORY_STATES, 'inventory status'),
availableQuantity: integer(
input.availableQuantity,
0,
1000000,
'available quantity',
),
totalAdmissions: integer(
input.totalAdmissions,
0,
1000000,
'total admissions',
),
unitPrice: input.unitPrice === null ? null : money(input.unitPrice),
};
}
function pricingQuote(value) {
const input = exactObject(
value,
['object', 'eventId', 'currency', 'lines', 'total', 'quotedAt'],
'pricing quote',
);
if (
!Array.isArray(input.lines) ||
input.lines.length < 1 ||
input.lines.length > 50
) {
throw new Error('Invalid pricing lines');
}
return {
object: oneOf(input.object, new Set(['pricing_quote']), 'pricing object'),
eventId: uuid(input.eventId, 'pricing event id'),
currency: oneOf(input.currency, new Set(['NGN']), 'pricing currency'),
lines: input.lines.map((value) => {
const line = exactObject(
value,
['tier', 'quantity', 'unitPrice', 'subtotal', 'availableAtPrice'],
'pricing line',
);
return {
tier: ticketCategory(line.tier),
quantity: integer(line.quantity, 1, 50, 'pricing quantity'),
unitPrice: money(line.unitPrice),
subtotal: money(line.subtotal),
availableAtPrice: integer(
line.availableAtPrice,
1,
1000000,
'available at price',
),
};
}),
total: money(input.total),
quotedAt: timestamp(input.quotedAt, 'quote time'),
};
}
function browserBrand(value) {
const input = exactObject(
value,
[
'brandName',
'logoUrl',
'primaryColor',
'accentColor',
'checkoutFooter',
'ticketFooter',
'emailFooter',
'supportEmail',
'emailMode',
],
'branding',
);
const primaryColor = text(input.primaryColor, 'primary colour', 7);
const accentColor = text(input.accentColor, 'accent colour', 7);
if (!HEX.test(primaryColor) || !HEX.test(accentColor)) {
throw new Error('Invalid brand colour');
}
nullableText(input.ticketFooter, 'ticket footer', 500);
nullableText(input.emailFooter, 'email footer', 500);
nullableText(input.supportEmail, 'support email', 254);
oneOf(input.emailMode, new Set(['ZENTRY_SENDER']), 'email mode');
return {
brandName: text(input.brandName, 'brand name', 120),
logoUrl:
input.logoUrl === null ? null : httpsUrl(input.logoUrl, 'brand logo URL'),
primaryColor: primaryColor.toUpperCase(),
accentColor: accentColor.toUpperCase(),
checkoutFooter: nullableText(input.checkoutFooter, 'checkout footer', 500),
};
}
function browserOrder(value) {
const input = exactObject(
value,
[
'id',
'object',
'eventId',
'status',
'gross',
'floatDebited',
'commission',
'balances',
'buyer',
'branding',
'lines',
'confirmedAt',
'createdAt',
],
'order',
);
const gross = money(input.gross);
const floatDebited = money(input.floatDebited);
if (
gross.amount !== floatDebited.amount ||
gross.currency !== floatDebited.currency
) {
throw new Error('Invalid float debit');
}
const commission = exactObject(
input.commission,
['amount', 'rate'],
'commission',
);
money(commission.amount);
const rate = exactObject(
commission.rate,
['id', 'value', 'unit'],
'commission rate',
);
uuid(rate.id, 'commission rate id');
text(rate.value, 'commission rate value', 32);
oneOf(rate.unit, RATE_UNITS, 'commission rate unit');
const balances = exactObject(
input.balances,
['floatBefore', 'floatAfter', 'earningsBefore', 'earningsAfter'],
'balances',
);
money(balances.floatBefore);
money(balances.floatAfter);
money(balances.earningsBefore);
money(balances.earningsAfter);
const buyer = exactObject(
input.buyer,
['email', 'fullname', 'phone'],
'buyer',
);
text(buyer.email, 'buyer email', 320);
nullableText(buyer.fullname, 'buyer name', 200);
nullableText(buyer.phone, 'buyer phone', 50);
timestamp(input.createdAt, 'order creation time');
if (
!Array.isArray(input.lines) ||
input.lines.length < 1 ||
input.lines.length > 50
) {
throw new Error('Invalid order lines');
}
return {
id: uuid(input.id, 'order id'),
object: oneOf(input.object, new Set(['order']), 'order object'),
eventId: uuid(input.eventId, 'order event id'),
status: oneOf(input.status, ORDER_STATES, 'order status'),
gross,
branding: browserBrand(input.branding),
lines: input.lines.map((value) => {
const line = exactObject(
value,
['id', 'admissionId', 'title', 'tier', 'unitPrice'],
'order line',
);
return {
id: uuid(line.id, 'order line id'),
admissionId: uuid(line.admissionId, 'admission id'),
title: text(line.title, 'order line title', 200),
tier: ticketCategory(line.tier),
unitPrice: money(line.unitPrice),
};
}),
confirmedAt: timestamp(input.confirmedAt, 'confirmation time'),
};
}
export function projectZentrySuccess(operation, value) {
const input = exactObject(value, ['data', 'meta'], 'success envelope');
if (operation === 'events.list') {
if (!Array.isArray(input.data) || input.data.length > 100) {
throw new Error('Invalid event list');
}
return {
data: input.data.map(publicEvent),
meta: responseMeta(input.meta, true),
};
}
if (operation === 'events.inventory') {
if (!Array.isArray(input.data) || input.data.length > MAX_TICKET_CATEGORIES) {
throw new Error('Invalid inventory list');
}
return {
data: input.data.map(inventoryTier),
meta: responseMeta(input.meta, false),
};
}
if (operation === 'pricing.quote') {
return {
data: pricingQuote(input.data),
meta: responseMeta(input.meta, false),
};
}
if (operation === 'checkout.create') {
return {
data: browserOrder(input.data),
meta: responseMeta(input.meta, false),
};
}
throw new Error('Unsupported operation');
}
export function projectZentryError(value) {
const input = exactObject(value, ['error', 'meta'], 'error envelope');
const error = exactObject(
input.error,
['code', 'message', 'details'],
'error',
['details'],
);
if (error.details !== undefined)
exactObject(error.details, Object.keys(error.details), 'details');
const code = text(error.code, 'error code', 100);
if (!ERROR_CODE.test(code)) throw new Error('Invalid error code');
return {
error: {
code,
message: text(error.message, 'error message', 2000),
},
meta: responseMeta(input.meta, false),
};
}
Import that module at the response boundary of your proxy. This second file is executable as-is and turns parse failures or contract drift into a generic response without logging or returning the rejected body:
// app/api/zentry-proxy/project-upstream-response.js
import {
projectZentryError,
projectZentrySuccess,
} from './project-response.js';
export async function projectUpstreamResponse(operation, upstreamResponse) {
try {
const upstreamEnvelope = await upstreamResponse.json();
const projected = upstreamResponse.ok
? projectZentrySuccess(operation, upstreamEnvelope)
: projectZentryError(upstreamEnvelope);
return json(projected, upstreamResponse.status);
} catch {
return json(
{
error: {
code: 'INVALID_UPSTREAM_RESPONSE',
message:
'The ticket service is unavailable right now. Please try again shortly.',
},
meta: { requestId: `proxy_${crypto.randomUUID()}` },
},
502,
);
}
}
function json(body, status) {
return Response.json(body, {
status,
headers: { 'Cache-Control': 'no-store' },
});
}
After your operation allowlist, request validation, authentication, CSRF checks, event publication
checks and upstream fetch have run, finish the route with
return projectUpstreamResponse(operation, upstreamResponse). These two files are the complete
response-projection boundary; they are not a replacement for the proxy route requirements above.
Never return the upstream Response, and never log a rejected response body or its buyer fields.
If the widget reports Invalid order field: floatDebited, the checkout has reached a proxy that
forwarded the full public order into the browser. The public API order is correct and may already be
committed. Reuse the same idempotency key, install the projector above, and verify the returned
order ID; never delete fields one by one or add private fields to the widget allowlist. The next raw
field would fail in turn, and allowing it would expose financial snapshots or buyer PII.
/api/zentry-proxy below is your endpoint, not a Zentry one — it exists so the API key stays
on your server. Build it first: the requirements it must satisfy are listed immediately after this
snippet. The browser transport converts a failed HTTP response into an exception, and treats a
non-JSON body as a failure rather than parsing it:
async function zentryTransport(operation, payload) {
const response = await fetch('/api/zentry-proxy', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
// Add the anti-CSRF header required by your own application here.
},
body: JSON.stringify({ operation, payload }),
});
// Read the body as text and parse it deliberately. `/api/zentry-proxy` is your own endpoint, so
// until it exists — or when it is behind a gateway, a login wall or a framework 404 — it answers
// with HTML. Calling `response.json()` on that throws `Unexpected token '<'`, which is what the
// buyer then sees, and none of the handling below is ever reached.
const raw = await response.text();
let envelope;
try {
envelope = raw ? JSON.parse(raw) : {};
} catch {
throw new ZentryTransportError(
response.status,
'PROXY_RESPONSE_NOT_JSON',
'The checkout service is unavailable.',
null,
);
}
if (!response.ok) {
const message =
typeof envelope?.error?.message === 'string'
? envelope.error.message
: 'The ticket request could not be completed.';
const code =
typeof envelope?.error?.code === 'string'
? envelope.error.code
: 'REQUEST_FAILED';
throw new ZentryTransportError(
response.status,
code,
message,
envelope?.meta?.requestId,
);
}
return envelope;
}
class ZentryTransportError extends Error {
constructor(status, code, message, requestId) {
super(message);
this.name = 'ZentryTransportError';
this.status = status;
this.code = code;
this.requestId = typeof requestId === 'string' ? requestId : null;
}
}
The storefront catches ZentryTransportError. When error.status === 409 and
error.code === 'COMPANY_VERIFICATION_REQUIRED', disable new checkout, retain the buyer's basket,
show a temporary company-verification message without a private reason, and notify the company
administrator. Do not discard the code by throwing a plain Error.
The /api/zentry-proxy path above belongs to the integrator; it is not a Zentry endpoint. Its
server handler must add Authorization: Bearer <server-side key> and forward X-Request-Id when
calling the public API. Do not put the key in JavaScript, HTML, browser storage, a query string,
iframe state, or a postMessage payload.
Mount the iframe embed
Use the web origin supplied during onboarding; do not derive it from the API origin. Load the
versioned SDK and let mountIframe construct the iframe URL:
<div id="zentry-checkout"></div>
<script src="https://<supplied-web-origin>/embeds/v1/zentry.js"></script>
<script>
const zentryWebOrigin = 'https://<supplied-web-origin>';
const checkout = window.ZentryEmbedsV1.mountIframe({
target: '#zentry-checkout',
embedUrl: `${zentryWebOrigin}/embed/v1/events`,
transport: zentryTransport,
title: 'Acme event checkout',
initialQuery: { limit: 25 },
brand: {
brandName: 'Acme Events',
logoUrl: 'https://<tenant-cdn-origin>/acme-logo.png',
primaryColor: '#111827',
accentColor: '#2563EB',
checkoutFooter: 'Tickets fulfilled by Zentry.',
},
});
// checkout.refresh() reloads the event list.
window.addEventListener('pagehide', () => checkout.destroy(), { once: true });
</script>
Replace the angle-bracket placeholders with origins supplied or controlled by you; they are not
declared Zentry hostnames. Pass the exact HTTPS URL ending in /embed/v1/events, with no query.
mountIframe appends hostOrigin=<window.location.origin> itself. Never hand-author that query or
add a tenant slug, API key, or secret to it.
The frame has sandbox="allow-scripts allow-same-origin allow-forms", an empty Permissions Policy, and
no-referrer. Zentry responds with a dynamic frame-ancestors CSP containing only the exact host
origin and with connect-src 'none'. The bridge checks both the exact message origin and the exact
source window in both directions. Its ready/request/response/resize messages are private
implementation details, not a host API.
Mount the script embed
For a same-page component, call mount instead. It renders into an open Shadow DOM and loads the
versioned stylesheet from the SDK asset directory:
<div id="zentry-checkout"></div>
<script src="https://<supplied-web-origin>/embeds/v1/zentry.js"></script>
<script>
const checkout = window.ZentryEmbedsV1.mount({
target: '#zentry-checkout',
transport: zentryTransport,
initialQuery: { limit: 25 },
brand: {
brandName: 'Acme Events',
primaryColor: '#111827',
accentColor: '#2563EB',
},
// Supply your CSP nonce when custom colours create an inline theme style.
styleNonce: window.__CSP_NONCE__,
});
window.addEventListener('pagehide', () => checkout.destroy(), { once: true });
</script>
The stable assets are:
/embeds/v1/zentry.js, exposingwindow.ZentryEmbedsV1version1.0.0;/embeds/v1/zentry.d.ts, containing the TypeScript transport and configuration types; and/embeds/v1/widget.css, loaded bymount(override its directory only withassetBase).
Both mount functions take a target selector or Element, the transport above, an optional
initialQuery, and an optional brand. Brand accepts brandName, an HTTPS logoUrl (query strings
are allowed; credentials and fragments are not), six-digit hex primaryColor/accentColor, and
checkoutFooter. mountIframe also
takes embedUrl and optional title; mount also takes optional assetBase and styleNonce.
Both return only refresh(): Promise<void> and destroy(): void. Phase 1 emits no browser business
events; consume order.created from the signed webhook stream when another system needs the order.
For a restrictive host CSP, allow the supplied web origin in script-src and style-src, and for
iframe mode in frame-src; allow only your own proxy in connect-src. Prefer an external host
script or a nonce for the initialization block. Pass the same nonce as styleNonce when custom
colours are used. The SDK rejects credential-shaped fields and projects both requests and responses
through fixed schemas, but those checks do not replace the proxy boundary.
API-key lifecycle
The company administrator manages keys in Business Suite at
/business/{tenantId}/api-keys on the supplied web-app origin. The management plane uses the
administrator's signed-in user session and COMPANY:api_key:* permissions; it is not part of the
public API-key contract.
For an initial issue, supply a descriptive 2–80 character name and a non-empty, duplicate-free
subset of the seven fixed grants. Copy the returned zentry_live... secret directly into the
secret manager: issue and rotation responses are Cache-Control: no-store, and later list calls
expose status, grants, rotation ancestry, and usage timestamps but never the secret or digest.
Initial issue and rotation also require a current KYB approval or the explicit legacy rollout
grace state. Internal v2 returns 409 with Current company verification is required when this
gate closes. Open the Verification page, complete/renew approval, and retry; revocation remains
available so a compromised credential can always be disabled.
A company can have at most two active keys. Rotation deliberately creates an active successor with the predecessor's name and grants, without revoking the predecessor:
- Keep one active key during steady state.
- Select Rotate. Copy the one-time successor secret into the secret manager immediately; the
response is marked
Cache-Control: no-storeand the secret cannot be retrieved later. - Deploy the successor to every caller while the predecessor remains valid.
- Make a real authenticated request and confirm the successor's
lastUsedAtin the dashboard. - Revoke the predecessor only after all callers have moved.
This preserves rollback during rollout and avoids an outage. If two keys are already active, a third issue or rotation is refused; identify and revoke the unused one while keeping at least one working credential. Revocation is idempotent. A public write also rechecks key status and its exact grant inside the mutation transaction, so a stale in-memory authentication snapshot cannot commit after a completed revocation.
Configure webhooks
The company administrator manages endpoints at /business/{tenantId}/webhooks on the supplied
web-app origin. Use a publicly resolvable HTTPS receiver; localhost, literal IP addresses,
private/link-local ranges, embedded credentials, URL fragments, and redirects are rejected or not
followed. For local development, use a controlled HTTPS tunnel whose hostname resolves only to
public addresses.
Register the smallest event set the receiver needs. Save the one-time zentry_whsec_... secret in
the receiver's secret manager. A company can register at most ten endpoints.
Verify webhooks
This Node.js verifier mirrors the server's signing contract. Pass the exact raw request bytes, not an object produced by a JSON body parser.
import { createHmac, timingSafeEqual } from 'node:crypto';
const SIGNATURE = /^t=(\d{1,12}),v1=([0-9a-f]{64})$/;
const TOLERANCE_SECONDS = 300;
export function verifyZentryWebhook({ secret, rawBody, signature, now }) {
const match = SIGNATURE.exec(signature ?? '');
if (!match || !Buffer.isBuffer(rawBody)) return false;
const timestamp = Number(match[1]);
const nowSeconds = Math.floor((now ?? Date.now()) / 1000);
if (Math.abs(nowSeconds - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret)
.update(`${timestamp}.`, 'utf8')
.update(rawBody)
.digest();
const candidate = Buffer.from(match[2], 'hex');
return (
candidate.length === expected.length && timingSafeEqual(candidate, expected)
);
}
An Express receiver must install the raw parser for this route before a global JSON parser:
import express from 'express';
import { verifyZentryWebhook } from './verify-zentry-webhook.js';
const app = express();
app.post(
'/webhooks/zentry',
express.raw({ type: 'application/json', limit: '1mb' }),
async (request, response) => {
const valid = verifyZentryWebhook({
secret: process.env.ZENTRY_WEBHOOK_SECRET,
rawBody: request.body,
signature: request.get('x-zentry-signature'),
});
if (!valid) return response.sendStatus(400);
const event = JSON.parse(request.body.toString('utf8'));
if (event.api_version !== 'v1') return response.sendStatus(400);
if (
event.id !== request.get('x-zentry-event-id') ||
event.type !== request.get('x-zentry-event')
) {
return response.sendStatus(400);
}
// Insert the event id under a UNIQUE constraint and enqueue local processing in one
// transaction. A duplicate event is already accepted and must not repeat side effects.
await inbox.storeOnceAndEnqueue(event.id, event);
return response.sendStatus(204);
},
);
app.use(express.json());
inbox is the receiver's database adapter, not a Zentry package. Its
storeOnceAndEnqueue(eventId, event) operation must start one database transaction, insert an inbox
row whose eventId has a unique constraint, enqueue local work only if that insert won, and commit.
On a duplicate key it commits no new work and resolves successfully. That is the durable boundary
which makes returning 204 safe across process restarts and manual replay.
Respond with 2xx only after the event is durably accepted. Do slow work asynchronously. Zentry
retries the same delivery automatically, so an in-memory dedupe set is insufficient.
Deduplicate on event.id / X-Zentry-Event-Id. X-Zentry-Delivery-Id is useful for attempt
diagnostics but changes on a manual replay. Compare the event-ID and event-type headers with the
parsed body and reject an unexpected mismatch before enqueueing.
Rotate a webhook secret
Webhook secret rotation is also zero-downtime and two-phase:
- Prepare rotation in the dashboard and store the newly revealed secret.
- Deploy verification that accepts either the current or pending secret. The header has no secret version, so verify against both.
- Activate the pending version in the dashboard.
- Keep the prior secret until all deliveries created under it have succeeded, failed terminally, or been replayed. Automatic retries retain their original signing version.
- Remove the old secret.
Do not activate before the receiver accepts the pending secret.
Failure handling and replay
Zentry treats every non-2xx, timeout, DNS/TLS error, or connection failure as unsuccessful.
There are eight total attempts with exponential backoff beginning at five seconds, spanning about
ten minutes. The delivery list exposes PENDING, SUCCEEDED, FAILED, and CANCELLED, attempt
count, last HTTP status, and a bounded error string.
After correcting a receiver, use Replay on the failed delivery. Replay makes a new delivery with the same immutable event/body and current endpoint URL/signing version. It is still safe only when the receiver deduplicates by event ID. Disabling an endpoint cancels pending work; re-enable it before replay.
Float, commission, and settlement operations
The public API can spend float but cannot fund it or withdraw earnings.
- Zentry onboarding or platform finance manages commission rates through PLATFORM-only controls;
company credentials and public API keys cannot schedule or change them. Rates are append-only
entries with explicit
value,unit, andeffectiveFrom. Valid inclusive maxima are10000basis points,100percent, or1fraction. At checkout Zentry locks the latest rate whoseeffectiveFromis at or before that transaction's time. An empty schedule, or a schedule containing only future rates, refuses checkout; no default is inferred. - Float is credited only by a PLATFORM administrator holding
tenant_float:credit, after a provider settlement is verified. A COMPANY credential and a public API key cannot self-credit. The target must be an active company with an active NGNFLOATwallet. The amount must be positive with at most two decimal places, and the stable provider reference must be 8–128 characters: an ASCII letter or digit followed only by letters, digits,.,_,:, or-. A current KYB approval or explicit rollout grace is also required; internal v2 returns409withCurrent company verification is requiredbefore any wallet or ledger mutation. Restore verification, then replay the same provider reference and amount. - Treat the provider reference as a tenant-scoped funding idempotency key. The same reference and
amount, including concurrent submissions, credits once; a replay returns
applied: falsewith the original balance movement. Reusing it for a different amount is a409and moves no money. - Alert on
balance.low. It fires once when checkout crosses from at leastNGN 1000.00to below it, not on every later sale below the threshold. The dashboard's low-balance flag is true only while the current float is strictly belowNGN 1000.00. - Alert separately on
INSUFFICIENT_FLOAT; checkout stops atomically at zero available capacity. - Read the dashboard's
FLOATvalue as selling capacity andEARNINGSas withdrawable commission. They are intentionally never merged. - A settlement request has no amount field. It requires an active company, an active NGN
EARNINGSwallet with a balance above zero, the company owner's payout bank account, and no existingPENDING,PROCESSING, orAPPROVEDrequest. It atomically debits the whole current earnings balance and starts asPENDING; it never debitsFLOAT. Opening the settlement is KYB-gated in the same way and returns internal v2409withCurrent company verification is requiredbefore creating the request or debiting earnings. - Settlement states are
PENDING,PROCESSING,APPROVED,COMPLETED,REJECTED, andFAILED. Phase 1 staff approval completes the manual payout. Rejection conditionally refunds the exact original amount to that company'sEARNINGSwallet once, with a guarded ledger entry. Dashboard and list responses expose only the bank account's last four digits. - Reconcile commission with the immutable order rate ID, value, and unit or with
commission.accrued; do not recompute historical orders from the company's current rate.
White-label behavior
The company administrator manages white-label configuration at /business/{tenantId}/branding on
the supplied web-app origin. This management surface is not available to public API keys.
brandNameis required after normalization, has a maximum of 120 characters, and cannot contain control characters.logoUrlis nullable and has a maximum of 2,048 characters. It must be HTTPS on a public-looking hostname and cannot contain credentials or a fragment.primaryColorandaccentColorare exact six-digit hex colors and normalize to uppercase.checkoutFooter,ticketFooter, andemailFooterare nullable text with a maximum of 500 characters each. Treat them as text, never trusted HTML.supportEmailis nullable, has a maximum of 254 characters, and normalizes to lowercase.
An omitted patch field keeps its current value; null clears a nullable field. Phase 1 exposes
only emailMode: "ZENTRY_SENDER": tenant branding is rendered, while Zentry's verified sender
domain delivers the transactional message. A tenant-controlled sender-domain or DNS setup is not
part of Phase 1.
Checkout snapshots every branding field. Use order.branding when rendering historical tickets
or receipts, not the company's current branding endpoint. This prevents a later rebrand from
changing what the buyer was issued.
Zentry's email renderer escapes tenant-controlled content. Do the same in every integrator-owned receipt or ticket renderer.
Automated acceptance smoke
The repository includes scripts/public-api-v1-smoke.mjs, which runs the documented event →
inventory → quote → checkout → order → validation path. It verifies the published OpenAPI graph,
strict no-coercion input behavior, safe event/inventory patches, canonical multi-tier quote order,
and operation-level idempotency.
Before credentials or funding are ready, --openapi-only performs the non-mutating deployed
contract checks with only ZENTRY_PUBLIC_API_BASE_URL configured.
export ZENTRY_PUBLIC_API_BASE_URL='https://<supplied-api-origin>/api/public/v1'
read -r -s -p 'Zentry API key: ' ZENTRY_PUBLIC_API_KEY
export ZENTRY_PUBLIC_API_KEY
printf '\n'
export ZENTRY_SMOKE_BUYER_EMAIL='[email protected]'
export ZENTRY_SMOKE_RUN_ID='partner-launch-001'
node scripts/public-api-v1-smoke.mjs
The run ID makes every idempotency key deterministic. Re-running the same ID against the same company returns the original event, inventory mutation, order, and first validation instead of charging float twice. Choose a new run ID only when a new acceptance order is intended.
Troubleshooting
| Symptom | What to check |
|---|---|
401 AUTHENTICATION_FAILED | Use Authorization: Bearer ...; confirm the key and company are active; complete rotation before revoking the predecessor. |
403 PERMISSION_DENIED | Compare the endpoint to the exact grant table; a read grant never implies manage. |
400 INVALID_REQUEST | Remove unknown fields; send money as strings; use exact enum casing; inspect details.violations. |
400 INVALID_CURSOR | Do not decode or edit cursors; restart the correct resource/filter listing. |
409 CONFLICT on retry | The key was reused with different normalized input, or the original is still processing. Retry only the identical request. |
409 PRICING_UNAVAILABLE | Reduce quantity, choose a tier with sufficient stock at one exact price, or update inventory. |
409 INVENTORY_UNAVAILABLE | Re-quote and ask the buyer to accept any new price; the quote was not a reservation. |
409 COMMISSION_RATE_UNAVAILABLE | Contact Zentry onboarding or platform finance to schedule an explicit, effective rate with a unit; company credentials cannot do this. |
409 COMPANY_VERIFICATION_REQUIRED | Open /business/{tenantId}/verification, complete requested changes, submit, and wait until transaction access/current approval is shown. After a definitely refused attempt, the idempotency key was not claimed. After an ambiguous timeout, restore eligibility and retry the same body/key because the original may already exist. |
409 INSUFFICIENT_FLOAT | Fund verified prepaid float; never retry checkout continuously while unfunded. |
409 CHECKOUT_UNAVAILABLE | Escalate the balance provisioning/activation state; do not build a parallel wallet workaround. |
Invalid order field: floatDebited | The proxy returned the full public order to the browser, and checkout may already have committed. Reuse the same idempotency key, run the full upstream response through projectZentrySuccess, and return its browser-safe result. Do not delete fields one by one or widen the widget schema. |
| Webhook signature mismatch | Capture raw bytes before JSON parsing, use <timestamp>.<body>, check receiver clock, and retain the old secret during rotation drain. |
| Webhook keeps retrying | Return 2xx within 10 seconds after durable acceptance; do not redirect; inspect delivery status and last HTTP result. |
A scan says ALREADY_VALIDATED | Treat it as a used ticket and show the original location/time; a transport retry must reuse the first key. |
Go-live checklist
- Record the supplied production and non-production API origins; both end in
/api/public/v1. - Fetch and archive the deployed
openapi.json; verify the title/version before tests. - Keep the public API client separate from every internal
/api/v2client. - Confirm the Verification page shows transaction access, record
approvalExpiresAt, assign renewal ownership, and alert before expiry; Phase 1 has no expiry webhook. - Store API and webhook secrets only in a managed secret store; redact request headers and buyer PII from logs.
- Use least-privilege keys per workload and owner; keep one active key in steady state so the second slot is available for rotation.
- Persist idempotency keys before sending mutations and reuse them after timeouts/restarts.
- Parse money as decimal strings/minor units, never binary floating point.
- Implement cursor termination from
hasMore; keep cursors opaque and filter-specific. - Keep the embed API key only in the server proxy; strict-allowlist its four operations, force
PUBLISHED, check publication before every event-specific call and both before and after an inventory read, discard inventory if either proof fails, project every upstream envelope before responding, authenticate/rate-limit callers, and test both embed modes under the production CSP. - Handle every documented error code, especially re-quote, float funding, and missing-rate operator paths.
- Confirm active branding, explicit commission unit/effective time, verified float, and test
inbox consent, plus a storefront failure state for
COMPANY_VERIFICATION_REQUIREDthat preserves the basket and notifies a company administrator. - Run
node scripts/public-api-v1-smoke.mjswith a fresh run ID and keep its IDs/request IDs in the launch record. - Verify webhook signatures against raw bytes with a five-minute tolerance and synchronized receiver clocks.
- Put webhook event IDs under a database uniqueness constraint and acknowledge only after a durable inbox write.
- Exercise automatic retry and manual replay; prove neither repeats a business side effect.
- Exercise two-phase API-key and webhook-secret rotation before production traffic.
- Alert on authentication failure, permission denial, insufficient float, low balance, webhook
terminal failure, and sustained
429/5xxrates. - Confirm transactional fulfilment messaging is expected and that dormant buyers are excluded from Zentry marketing.
- Reconcile
order.createdandcommission.accruedindependently by immutable order ID and explicit rate unit. - Keep float funding and whole-earnings settlement in operator workflows, never in public API credentials.