The Zentry Public API is the stable Business Suite data-plane contract. It is separate from
Zentry's internal /api/v2 routes and uses API-key authentication, its own presenters, and its
own response envelope.
For a guided first integration, start with the integration guide. The deployed OpenAPI document is always available at:
- Integration guide:
<origin>/api/public/v1/guide - Swagger UI:
<origin>/api/public/v1/docs - OpenAPI JSON:
<origin>/api/public/v1/openapi.json
Use the API origin supplied during onboarding. For a local server on the default port, the base
URL is http://localhost:4001/api/public/v1 (the API's default PORT).
Protocol
Authentication
Send the API key as an HTTP Bearer credential on every request:
Authorization: Bearer zentry_live.<key-id>.<secret>
X-API-Key is not accepted. Keys are scoped to exactly one company and carry only the fixed grants
listed in Permissions. A revoked key, a key owned by an inactive company, or a
malformed credential returns the same 401 AUTHENTICATION_FAILED contract.
The secret is shown once when a key is issued or rotated. Zentry stores a keyed digest, not a recoverable credential. Do not put keys in browser code, URLs, logs, screenshots, analytics, or source control.
Request and response headers
| Header | Direction | Required | Contract |
|---|---|---|---|
Authorization | Request | Yes | Bearer <API key> |
Content-Type | Request | For JSON bodies | application/json |
Idempotency-Key | Request | On the four idempotent mutations | 8–128 characters matching [A-Za-z0-9][A-Za-z0-9._:-]* |
X-Request-Id | Request | No | 1–128 safe characters matching [A-Za-z0-9][A-Za-z0-9._:-]* |
X-Request-Id | Response | Always | The accepted caller value or a server-generated UUID |
The response meta.requestId and X-Request-Id header are the same value. Include it in support
requests. An invalid caller-supplied request ID is replaced rather than rejected.
Success envelope
Single-resource and non-paginated list responses use:
{
"data": { "id": "8e40c61b-f27c-48b0-9cf2-f9c324a4992c" },
"meta": { "requestId": "partner.checkout.0182" }
}
Cursor-paginated responses add meta.page:
{
"data": [],
"meta": {
"requestId": "partner.orders.0183",
"page": { "hasMore": false, "nextCursor": null }
}
}
The public envelope deliberately does not use the internal v2 ok discriminant. Do not unwrap
public responses with an internal-route client.
Every response member shown in the resource examples is present on the wire. A nullable member is
emitted as null, not omitted. The only conditional envelope members are error details and
cursor meta.page (which appears only on paginated lists).
Error envelope
Every public error has a string error.message:
{
"error": {
"code": "INVALID_REQUEST",
"message": "Validation failed: eventId must be a UUID",
"details": {
"violations": [{ "message": "eventId must be a UUID" }]
}
},
"meta": { "requestId": "partner.checkout.0182" }
}
details is optional. Never parse message to make a decision; branch on the HTTP status and
code.
| HTTP | Code | Meaning |
|---|---|---|
400 | INVALID_REQUEST | Body, query, path input, money, or an idempotency key is invalid. Unknown fields are rejected. |
400 | INVALID_CURSOR | The cursor is malformed or belongs to another resource. |
400 | IDEMPOTENCY_KEY_REQUIRED | An idempotent mutation omitted its key. |
401 | AUTHENTICATION_FAILED | The Bearer credential is absent, malformed, revoked, or inactive. |
403 | PERMISSION_DENIED | The key lacks the exact operation grant, or its write authority changed before commit. |
404 | NOT_FOUND | The resource does not exist in this company scope. Cross-company resources also appear absent. |
409 | CONFLICT | State or idempotency input conflicts with an earlier request. |
409 | INVENTORY_UNAVAILABLE | Checkout inventory is no longer available at the expected price. |
409 | PRICING_UNAVAILABLE | A quote cannot satisfy the quantity at one exact unit price. |
409 | COMMISSION_RATE_UNAVAILABLE | No explicit commission rate is effective. Contact Zentry onboarding or platform finance; company credentials cannot schedule one. |
409 | INSUFFICIENT_FLOAT | The prepaid float cannot fund the checkout. |
409 | CHECKOUT_UNAVAILABLE | The company's checkout balances are absent, inactive, or inconsistent. |
409 | COMPANY_VERIFICATION_REQUIRED | The company lacks a current KYB approval or an explicit rollout grace state. Complete or renew verification before checkout. |
429 | RATE_LIMITED | The request exceeded the current public limit. |
500+ | INTERNAL_ERROR | Unexpected failure. The message is always An unexpected error occurred. and contains no provider or database text. |
Money and time
- All Phase 1 commerce is in
NGN. - Every public money amount is a base-10, major-unit string. Output always has two fractional
digits, for example
{ "amount": "25000.00", "currency": "NGN" }. - Money inputs accept
0through999999999999.99, with at most two fractional digits. Never send a JSON floating-point number for money. - Date-times are UTC ISO 8601 strings such as
2026-08-26T12:00:00.000Z. - Event
dateisYYYY-MM-DD;startsAtandendsAtare 24-hourHH:mm;timezoneis an IANA name such asAfrica/Lagos.
Idempotency
These operations require an Idempotency-Key:
POST /eventsPOST /events/{eventId}/inventoryPOST /checkoutsPOST /events/{eventId}/admissions/{admissionId}/validate
The key is unique per company and operation, not per API credential. This means the same key can
safely be retried after rotating to the successor key. Identical normalized input returns the
original persisted result. Reusing the same key for different semantic input returns 409 CONFLICT; an in-flight original may return the same code with Idempotent request is still processing, in which case retry the identical request later.
Object member order does not affect the request identity. Normalization includes such things as a lower-cased buyer email and sorted checkout tiers. Unknown request members are still rejected. Treat an idempotency key as permanently spent for that operation; never recycle one for a new business action.
Current API-key authority and KYB transaction eligibility are rechecked before an idempotent
checkout replay. After an ambiguous timeout, a later COMPANY_VERIFICATION_REQUIRED response does
not prove that the original checkout failed. Restore eligibility and retry the identical body and
key to recover the original result if it committed.
Company-verification boundary
Public v1 exposes no KYB case, private decision reason, or verification-preflight endpoint. A quote
success is not checkout readiness. Only POST /checkouts authoritatively evaluates current
approval or explicit rollout grace and returns the non-disclosing
409 COMPANY_VERIFICATION_REQUIRED when closed. Event/order reads and ticket validation remain
available. Direct a signed-in company administrator with exact COMPANY:kyb:read,
COMPANY:kyb:manage, and COMPANY:kyb:submit grants to
/business/{tenantId}/verification; public API keys can never hold those grants. Retry only after
that page shows transaction access. Monitor its approvalExpiresAt; Phase 1 emits no expiry
webhook or event.
Pagination
GET /events and GET /orders use cursor pagination in newest-first order.
limitdefaults to25and must be from1through100.cursoris opaque, resource-specific, and at most 512 characters.- Send
meta.page.nextCursorunchanged on the next request. - Stop when
meta.page.hasMoreisfalse;nextCursoris thennull. - A cursor from
/eventsis invalid on/orders.
Rate limit
The current application limit is 100 requests per 60-second window per client tracker (normally
the client IP after the configured trusted proxy boundary). A 429 uses the standard public error
envelope. Remaining-quota response headers are not part of the v1 contract. Back off with jitter;
do not retry a 429 in a tight loop.
Permissions
API keys may carry only these grants. Key, member, webhook, branding, dashboard, and money administration are intentionally unavailable to public credentials.
| Grant | Public operations |
|---|---|
COMPANY:event:read | List and retrieve events |
COMPANY:event:manage | Create, update, and cancel events |
COMPANY:inventory:read | Read inventory and request pricing quotes |
COMPANY:inventory:manage | Create, update, and cancel inventory |
COMPANY:checkout:create | Create an immediate prepaid-float checkout |
COMPANY:order:read | List and retrieve orders |
COMPANY:ticket:validate | Validate an admission at the gate |
Use separate least-privilege keys for a server-side storefront and gate devices when practical. A typical storefront needs event/inventory read, checkout create, and order read. A gate service usually needs only ticket validate.
Endpoint summary
All paths below are relative to <origin>/api/public/v1.
| Method | Path | Grant | Idempotent key | Success |
|---|---|---|---|---|
POST | /events | event:manage | Required | 201 event |
GET | /events | event:read | No | 200 event page |
GET | /events/{eventId} | event:read | No | 200 event |
PATCH | /events/{eventId} | event:manage | No | 200 event |
DELETE | /events/{eventId} | event:manage | No | 200 cancelled event |
GET | /events/{eventId}/inventory | inventory:read | No | 200 inventory list |
POST | /events/{eventId}/inventory | inventory:manage | Required | 201 inventory tier |
PATCH | /events/{eventId}/inventory/{tier} | inventory:manage | No | 200 inventory tier |
DELETE | /events/{eventId}/inventory/{tier} | inventory:manage | No | 200 inventory tier |
POST | /pricing/quote | inventory:read | No | 200 pricing quote |
POST | /checkouts | checkout:create | Required | 201 order |
GET | /orders | order:read | No | 200 order page |
GET | /orders/{orderId} | order:read | No | 200 order |
POST | /events/{eventId}/admissions/{admissionId}/validate | ticket:validate | Required | 200 validation |
The braces in path examples are placeholders and are not sent literally. Every resource ID in these paths is a UUID v4.
Events
Event object
{
"id": "5be046c8-49cf-420b-a840-2e1749afe837",
"object": "event",
"title": "Zentry Partner Summit",
"description": "A one-day industry summit.",
"status": "PUBLISHED",
"schedule": {
"date": "2026-10-15",
"startsAt": "09:00",
"endsAt": "17:30",
"timezone": "Africa/Lagos"
},
"location": { "name": "Victoria Island, Lagos", "venue": "Civic Centre" },
"imageUrl": "https://cdn.example.test/summit.jpg",
"organizerName": "Acme Events",
"category": null,
"createdAt": "2026-08-26T12:00:00.000Z",
"updatedAt": "2026-08-26T12:00:00.000Z"
}
Public event status is one of DRAFT, PUBLISHED, ENDED, or CANCELLED. Internal lifecycle
members never pass through to this vocabulary.
Create an event
POST /events requires COMPANY:event:manage and an idempotency key.
{
"title": "Zentry Partner Summit",
"description": "A one-day industry summit.",
"date": "2026-10-15",
"startsAt": "09:00",
"endsAt": "17:30",
"timezone": "Africa/Lagos",
"location": "Victoria Island, Lagos",
"venue": "Civic Centre",
"imageUrl": "https://cdn.example.test/summit.jpg",
"organizerName": "Acme Events",
"status": "PUBLISHED"
}
| Member | Required | Rules |
|---|---|---|
title | Yes | Non-empty string, max 200 |
description | No | String, max 5,000; blank normalizes to null |
date | Yes | Real calendar day in YYYY-MM-DD |
startsAt, endsAt | Yes | HH:mm |
timezone | Yes | Valid IANA timezone, max 100 |
location, venue | No | String, max 500 each |
imageUrl | Yes | HTTPS URL, max 2,000 |
organizerName | No | String, max 200 |
categoryId | No | UUID v4 of an active category. Omit it unless one was supplied during onboarding; public v1 has no category catalogue. |
status | Yes | DRAFT or PUBLISHED |
List events
GET /events?limit=25&status=PUBLISHED&cursor=<opaque> requires
COMPANY:event:read. status is optional and must be one of the four public event statuses.
Retrieve an event
GET /events/{eventId} requires COMPANY:event:read.
Update an event
PATCH /events/{eventId} requires COMPANY:event:manage. Send one or more members from the create
body. Transport-level status updates accept DRAFT and PUBLISHED; use DELETE to cancel.
Publishing a draft releases its paused, unsold inventory. Moving it back to draft pauses unsold inventory. A completed event cannot change, and a cancelled event cannot reopen.
Cancel an event
DELETE /events/{eventId} requires COMPANY:event:manage. It does not delete order history. It
sets the public event status to CANCELLED and cancels unsold inventory.
Inventory
Every inventory unit is a distinct, credentialed admission. Public reads aggregate those rows by tier.
Inventory tier object
{
"object": "inventory_tier",
"tier": "VIP",
"status": "AVAILABLE",
"availableQuantity": 25,
"totalAdmissions": 25,
"unitPrice": { "amount": "25000.00", "currency": "NGN" }
}
status is AVAILABLE, PAUSED, SOLD_OUT, or CANCELLED. unitPrice is null when no
unsold active or paused admission has a current price. If a tier contains multiple price bands,
the inventory summary reports the lowest current price; use a pricing quote before checkout.
tier is the exact name of a ticket category configured for the event. Add custom categories
when creating or updating an event with "ticketCategories": ["Backstage", "Table for 4"].
Event responses return the complete category list. Names are trimmed, limited to 60 characters,
and deduplicated without regard to case; use the returned spelling when creating stock, quoting,
or checking out. Control characters and JavaScript object property names are reserved.
An event can have up to 50 categories. Updates add categories and retain existing names so
previous tickets, orders and vouchers stay linked correctly.
These existing categories remain available for every event:
GENERAL_ADMISSIONVIPVVIPEARLY_BIRDREGULARSTUDENT
Read inventory
GET /events/{eventId}/inventory requires COMPANY:inventory:read and returns a non-paginated
array of tier objects. An event outside the company scope returns 404.
Create inventory
POST /events/{eventId}/inventory requires COMPANY:inventory:manage and an idempotency key.
{
"tier": "VIP",
"title": "VIP admission",
"description": "Includes lounge access.",
"unitPrice": "25000.00",
"currency": "NGN",
"isFree": false,
"quantity": 25,
"status": "AVAILABLE"
}
| Member | Required | Rules |
|---|---|---|
tier | Yes | One supported tier |
title | Yes | Non-empty string, max 200 |
description | No | String, max 2,000 |
unitPrice | Yes | Money string |
currency | Yes | NGN |
isFree | Yes | Must agree with the price: free is exactly true plus "0", "0.0", or "0.00" |
quantity | Yes | Integer from 1 through 1,000 |
status | Yes | AVAILABLE or PAUSED |
Creating AVAILABLE inventory on a draft event records it as paused until the event is published.
A later batch for the same tier adds admissions; it does not replace the earlier batch.
Update inventory
PATCH /events/{eventId}/inventory/{tier} requires COMPANY:inventory:manage.
{
"unitPrice": "27500.00",
"currency": "NGN",
"isFree": false,
"status": "AVAILABLE"
}
Send at least one change. If changing price, send unitPrice, currency, and isFree together.
The patch applies only to unsold, non-cancelled admissions in that tier. A terminal event or a tier
with no mutable admission returns 409.
Cancel inventory
DELETE /events/{eventId}/inventory/{tier} requires COMPANY:inventory:manage and cancels all
unsold admissions in that tier. Sold admissions and order history remain intact.
Pricing
Create a quote
POST /pricing/quote requires COMPANY:inventory:read.
{
"eventId": "5be046c8-49cf-420b-a840-2e1749afe837",
"lines": [{ "tier": "VIP", "quantity": 2 }]
}
Tiers must be unique. Each quantity is positive, and the total requested admissions across all lines cannot exceed 50.
{
"object": "pricing_quote",
"eventId": "5be046c8-49cf-420b-a840-2e1749afe837",
"currency": "NGN",
"lines": [
{
"tier": "VIP",
"quantity": 2,
"unitPrice": { "amount": "25000.00", "currency": "NGN" },
"subtotal": { "amount": "50000.00", "currency": "NGN" },
"availableAtPrice": 25
}
],
"total": { "amount": "50000.00", "currency": "NGN" },
"quotedAt": "2026-08-26T12:01:00.000Z"
}
A quote selects one exact unit price per tier and refuses to mix price bands. It is a current
price check, not a reservation. Response lines are in canonical lexicographic tier order,
regardless of request order. Correlate a response line by its tier value, never by array index.
Copy that tier's quoted unitPrice.amount into checkout as expectedUnitPrice; be prepared to
re-quote after INVENTORY_UNAVAILABLE.
Checkout
Create a checkout
POST /checkouts requires COMPANY:checkout:create and an idempotency key.
{
"eventId": "5be046c8-49cf-420b-a840-2e1749afe837",
"buyer": {
"email": "[email protected]",
"fullname": "Ada Buyer",
"phone": "+2348012345678"
},
"lines": [
{
"tier": "VIP",
"quantity": 2,
"expectedUnitPrice": "25000.00",
"currency": "NGN"
}
]
}
Buyer email is required, trimmed, lower-cased, and at most 320 characters. fullname (max 200) and
phone (max 50) are optional. Tiers must be unique and total quantity cannot exceed 50.
Checkout is immediate and atomic:
- Zentry rechecks the active company, current KYB authority, API key, exact grant, event, and expected inventory price.
- The full gross is guarded-debited from the company's prepaid
FLOATbalance. - Commission is computed from the latest explicit effective rate and credited to the separate
EARNINGSbalance. - Admissions, buyer record, immutable order, chained wallet ledger rows, notification, and outbound event facts commit together.
There is no fallback commission rate. A missing rate returns COMMISSION_RATE_UNAVAILABLE; Zentry
onboarding or platform finance must schedule it through PLATFORM-only controls. Company credentials
cannot do so. An insufficient float returns INSUFFICIENT_FLOAT; no partial order, inventory sale,
wallet movement, or commission accrual remains. A pricing quote does not reserve stock, so a
successful quote can still race another checkout and receive INVENTORY_UNAVAILABLE.
COMPANY_VERIFICATION_REQUIRED is also atomic for a definitely refused attempt: it is decided
before the idempotency claim, wallet, inventory, buyer, or order is changed. It contains no private
decision reason. Complete or renew KYB, then retry the same body and idempotency key. If the first
attempt timed out ambiguously, the verification error on a later retry does not prove the first
failed; restore eligibility before using that same body/key to recover the authoritative result.
The buyer is linked to an existing Zentry user with the same email or to a dormant, passwordless record. This linkage is for fulfilment and ticket lifecycle, not marketing consent.
Order object
{
"id": "6a98980d-5085-4c12-a301-8392d494a158",
"object": "order",
"eventId": "5be046c8-49cf-420b-a840-2e1749afe837",
"status": "CONFIRMED",
"gross": { "amount": "50000.00", "currency": "NGN" },
"floatDebited": { "amount": "50000.00", "currency": "NGN" },
"commission": {
"amount": { "amount": "1250.00", "currency": "NGN" },
"rate": {
"id": "bc9235b6-31fb-4a80-965c-d70ea2fa6b32",
"value": "250",
"unit": "BASIS_POINTS"
}
},
"balances": {
"floatBefore": { "amount": "100000.00", "currency": "NGN" },
"floatAfter": { "amount": "50000.00", "currency": "NGN" },
"earningsBefore": { "amount": "2000.00", "currency": "NGN" },
"earningsAfter": { "amount": "3250.00", "currency": "NGN" }
},
"buyer": {
"email": "[email protected]",
"fullname": "Ada Buyer",
"phone": "+2348012345678"
},
"branding": {
"brandName": "Acme Events",
"logoUrl": "https://cdn.example.test/acme-logo.png",
"primaryColor": "#111827",
"accentColor": "#2563EB",
"checkoutFooter": "Tickets fulfilled by Zentry.",
"ticketFooter": "Bring valid identification.",
"emailFooter": "Questions? Contact Acme Events.",
"supportEmail": "[email protected]",
"emailMode": "ZENTRY_SENDER"
},
"lines": [
{
"id": "7e8df606-7675-46ba-aa56-edc72e30b0dc",
"admissionId": "08549076-c35f-453d-909c-b124b7b13a99",
"title": "VIP admission",
"tier": "VIP",
"unitPrice": { "amount": "25000.00", "currency": "NGN" }
},
{
"id": "bdb425cc-cb54-4e71-a246-b45ab9b65b0b",
"admissionId": "586c0850-4a64-47d5-8c19-c8da06c907db",
"title": "VIP admission",
"tier": "VIP",
"unitPrice": { "amount": "25000.00", "currency": "NGN" }
}
],
"confirmedAt": "2026-08-26T12:02:00.000Z",
"createdAt": "2026-08-26T12:02:00.000Z"
}
One requested quantity becomes one order line per admission, so the example quantity of two
returns two distinct admissionId values. Keep those IDs for ticket presentation and gate
validation.
Commission rate.unit is always explicit:
BASIS_POINTS:250means 2.5% (250 / 10,000); the inclusive maximum is10000.PERCENT:2.5means 2.5% (2.5 / 100); the inclusive maximum is100.FRACTION:0.025means 2.5%; the inclusive maximum is1.
Checkout selects the latest rate whose effectiveFrom is not in the future, ordered by effective
time and then creation time. A future-only schedule is the same as no effective rate and refuses
checkout. Zentry applies the selected rate to gross in integer minor units and rounds exactly once,
half-up, to the nearest kobo.
Order buyer, commission, balances, line prices, and branding are immutable checkout snapshots.
Changing a company rate or branding later does not rewrite prior orders or tickets. Phase 1 email
uses ZENTRY_SENDER: Zentry sends tenant-branded transactional fulfilment; tenant sender-domain
DNS is not part of this phase.
Orders
List orders
GET /orders requires COMPANY:order:read.
| Query | Required | Rules |
|---|---|---|
limit | No | 1–100; default 25 |
cursor | No | Opaque cursor from the preceding order page |
eventId | No | UUID v4; restricts to one company event |
status | No | Comma-separated unique values from CONFIRMED,CANCELLED,REFUNDED; no spaces |
Example: GET /orders?eventId=<uuid>&status=CONFIRMED,REFUNDED&limit=50.
Retrieve an order
GET /orders/{orderId} requires COMPANY:order:read and returns the immutable order object.
Ticket validation
POST /events/{eventId}/admissions/{admissionId}/validate requires
COMPANY:ticket:validate and an idempotency key.
{ "location": "North gate" }
The body may be {}. location is optional, trims to null when blank, and has a maximum length
of 200.
{
"id": "65c487b6-4dc1-4cb2-83b9-2a7660d52f90",
"object": "ticket_validation",
"outcome": "ADMITTED",
"admissionId": "08549076-c35f-453d-909c-b124b7b13a99",
"eventId": "5be046c8-49cf-420b-a840-2e1749afe837",
"orderId": "6a98980d-5085-4c12-a301-8392d494a158",
"location": "North gate",
"ticket": {
"title": "VIP admission",
"tier": "VIP",
"branding": {
"brandName": "Acme Events",
"logoUrl": "https://cdn.example.test/acme-logo.png",
"primaryColor": "#111827",
"accentColor": "#2563EB",
"checkoutFooter": "Tickets fulfilled by Zentry.",
"ticketFooter": "Bring valid identification.",
"emailFooter": "Questions? Contact Acme Events.",
"supportEmail": "[email protected]",
"emailMode": "ZENTRY_SENDER"
}
},
"validatedAt": "2026-08-26T17:00:00.000Z"
}
The first atomic validation returns ADMITTED and emits ticket.validated. Retrying that request
with the same idempotency key returns the exact original response. A genuinely separate scan of
the same admission, using a new idempotency key, returns ALREADY_VALIDATED with the original
validation ID, location, and time. It does not emit a second webhook.
Use one idempotency key for all transport retries of one gate action. Use a new key only for a new operator scan attempt.
Browser embed transport
The Phase 1 iframe and script-mounted widgets use the public API through an integrator-owned server transport. They are not alternate API routes and never accept a tenant slug or credential. The SDK invokes only these operations:
| Operation | Payload | Public v1 mapping |
|---|---|---|
events.list | { limit?, cursor? } | GET /events; the proxy always adds status=PUBLISHED |
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 |
The transport resolves with a proxy-validated, browser-safe { data, meta } envelope, never the
raw public response. For checkout, data contains only id, object, eventId, status,
gross, the five public branding fields, admission lines, and confirmedAt. Buyer PII,
floatDebited, commission/rate, FLOAT/EARNINGS snapshots, private branding fields, and
createdAt must be absent from the proxy HTTP response itself. Errors are projected to
{ error: { code, message }, meta: { requestId } }.
The storefront list contract has no status field and always filters PUBLISHED. Before inventory,
quote, or checkout, the proxy retrieves that exact tenant event and refuses to continue unless its
current status is PUBLISHED; an arbitrary event ID cannot bypass this check. The API-only
inventory read intentionally supports draft management, so the proxy repeats this proof after a
successful inventory read and discards the body if publication changed. Pricing and checkout
already enforce active-event state inside their server operation; do not turn a committed checkout
into an ambiguous failure with a post-commit status check. Server-side code
must independently use the operation allowlist, validate requests and upstream responses, project
the browser envelope, add the API key, and rate-limit the caller. The minimum embed-key grants are
COMPANY:event:read,
COMPANY:inventory:read, and COMPANY:checkout:create.
On the supplied web origin, stable assets are /embeds/v1/zentry.js (global
window.ZentryEmbedsV1, version 1.0.0), /embeds/v1/zentry.d.ts, and
/embeds/v1/widget.css. The iframe document is /embed/v1/events, but hosts must create it through
ZentryEmbedsV1.mountIframe; the SDK adds the exact host-origin constraint. mount and
mountIframe both return refresh() and destroy() and expose no business-event callback in v1.
See the integration guide for the
proxy boundary, iframe and script examples, CSP, and lifecycle details.
Outbound webhooks
Webhook endpoints are configured in the authenticated Business Suite management surface, not with a public API key. A company may register up to 10 endpoints. Registration accepts a public HTTPS URL without credentials or a fragment, and at least one of:
order.createdcommission.accruedbalance.lowticket.validated
Delivery is asynchronous and at least once. The API response is authoritative; do not hold a
checkout response open while waiting for its webhook. There is no cross-type delivery-order
guarantee, so commission.accrued must remain safe if its corresponding order.created is delayed.
Changing an endpoint's subscriptions affects future dispatch and does not create deliveries for
older events that never targeted that endpoint.
The endpoint secret is shown once and starts with zentry_whsec_. Endpoint registration also
rejects local/private targets syntactically; delivery validates every DNS answer before opening a
direct TLS connection.
Delivery request
Zentry sends an HTTPS POST with the exact stored JSON body and these headers:
Content-Type: application/json; charset=utf-8
User-Agent: Zentry-Webhooks/1.0
X-Zentry-Event-Id: <event UUID>
X-Zentry-Event: order.created
X-Zentry-Delivery-Id: <delivery UUID>
X-Zentry-Signature: t=<Unix seconds>,v1=<64 lowercase hex characters>
The receiver must return any 2xx status within 10 seconds. Redirects are not followed. Response
bodies are ignored and limited to 64 KiB.
Every body uses this top-level shape:
{
"id": "b34ceadf-dfc6-4a60-a12e-75a8f451b330",
"type": "order.created",
"api_version": "v1",
"created_at": "2026-08-26T12:02:00.000Z",
"data": {}
}
id is the event ID and remains stable across automatic retries and manual replay. The delivery
ID identifies one delivery run and changes on manual replay. Deduplicate business side effects on
the event ID.
Signature
The signed payload is the UTF-8 bytes:
<timestamp>.<exact raw HTTP body>
The digest is HMAC-SHA256(signing_secret, signed_payload), lowercase hex. Reject malformed
headers, compare in constant time, and reject timestamps more than 300 seconds from the receiver's
clock. Verify before parsing JSON; parsing and re-serializing changes the signed bytes.
See the copy-paste Node verifier in the integration guide.
Retry and replay
Anything other than 2xx, a timeout, DNS/TLS failure, or a transport error is a failed attempt.
Automatic delivery has eight total attempts with exponential backoff starting at 5 seconds,
spanning approximately ten minutes. The same event ID, delivery ID, endpoint URL snapshot, body,
and signing-secret version are used across automatic attempts.
An exhausted attempt budget is retained in the delivery/dead-letter surfaces. A company user with
COMPANY:webhook:replay can manually replay an existing delivery. Replay creates a new delivery
ID, keeps the same event ID and body, and snapshots the endpoint's current URL and active signing
secret. Disabling an endpoint cancels pending attempts; re-enable it before replaying.
Secret rotation is two-phase: prepare and reveal the next secret, deploy receiver support for both secrets, then activate. Keep accepting the old secret until deliveries that snapshot its version are terminal, because an old automatic retry continues using the old version even after activation.
order.created
{
"id": "b34ceadf-dfc6-4a60-a12e-75a8f451b330",
"type": "order.created",
"api_version": "v1",
"created_at": "2026-08-26T12:02:00.000Z",
"data": {
"order": {
"id": "6a98980d-5085-4c12-a301-8392d494a158",
"event_id": "5be046c8-49cf-420b-a840-2e1749afe837",
"status": "CONFIRMED",
"currency": "NGN",
"gross_amount": "50000.00",
"float_debited_amount": "50000.00",
"commission": {
"amount": "1250.00",
"rate": {
"id": "bc9235b6-31fb-4a80-965c-d70ea2fa6b32",
"value": "250",
"unit": "BASIS_POINTS"
}
},
"balances": {
"float_before": "100000.00",
"float_after": "50000.00",
"earnings_before": "2000.00",
"earnings_after": "3250.00"
},
"buyer": {
"email": "[email protected]",
"name": "Ada Buyer",
"phone": "+2348012345678"
},
"lines": [
{
"admission_id": "08549076-c35f-453d-909c-b124b7b13a99",
"ticket_purchase_id": "aa49b926-5a6c-479e-95eb-eb44b754e630",
"title": "VIP admission",
"tier": "VIP",
"unit_price": "25000.00",
"currency": "NGN"
}
],
"confirmed_at": "2026-08-26T12:02:00.000Z"
}
}
}
One confirmed checkout also produces a separate commission.accrued event. Do not infer one by
double-processing the other. buyer.name and buyer.phone are always present and are each either
a string or null; buyer.email is always a string.
commission.accrued
{
"id": "6bb0bcbb-5eec-4267-98a4-a86b50f2f94a",
"type": "commission.accrued",
"api_version": "v1",
"created_at": "2026-08-26T12:02:00.000Z",
"data": {
"order_id": "6a98980d-5085-4c12-a301-8392d494a158",
"event_id": "5be046c8-49cf-420b-a840-2e1749afe837",
"currency": "NGN",
"amount": "1250.00",
"rate": {
"id": "bc9235b6-31fb-4a80-965c-d70ea2fa6b32",
"value": "250",
"unit": "BASIS_POINTS"
},
"earnings_balance_before": "2000.00",
"earnings_balance_after": "3250.00",
"accrued_at": "2026-08-26T12:02:00.000Z"
}
}
balance.low
{
"id": "a959763f-34d9-4435-ae72-60f376f449e4",
"type": "balance.low",
"api_version": "v1",
"created_at": "2026-08-26T12:02:00.000Z",
"data": {
"order_id": "6a98980d-5085-4c12-a301-8392d494a158",
"currency": "NGN",
"balance_before": "1050.00",
"balance_after": "950.00",
"threshold_amount": "1000.00"
}
}
This event is emitted only when checkout moves float from at or above 1000.00 to below it. A
wallet already below the threshold stays quiet until it is funded to at least the threshold and
crosses again.
ticket.validated
{
"id": "48746bad-4878-4b53-87ab-1666e554ec92",
"type": "ticket.validated",
"api_version": "v1",
"created_at": "2026-08-26T17:00:00.000Z",
"data": {
"admission_id": "08549076-c35f-453d-909c-b124b7b13a99",
"event_id": "5be046c8-49cf-420b-a840-2e1749afe837",
"order_id": "6a98980d-5085-4c12-a301-8392d494a158",
"validated_at": "2026-08-26T17:00:00.000Z"
}
}
Only the atomic first admission emits this event.
Versioning and compatibility
The major version is part of the URL and webhook body. Internal /api/v2 routes are not aliases
of this contract and must never be used as an integration fallback.
Within public v1, clients should ignore response members they do not understand and switch only on documented enum values. Request bodies are strict: do not send undocumented members. A removal, rename, or semantic break requires a future major public prefix; no retirement date for v1 is currently declared.