Zentry for Business

Developer documentation

API reference

Every endpoint with its schemas, errors, pagination and idempotency rules, plus the exact webhook event bodies.

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

HeaderDirectionRequiredContract
AuthorizationRequestYesBearer <API key>
Content-TypeRequestFor JSON bodiesapplication/json
Idempotency-KeyRequestOn the four idempotent mutations8–128 characters matching [A-Za-z0-9][A-Za-z0-9._:-]*
X-Request-IdRequestNo1–128 safe characters matching [A-Za-z0-9][A-Za-z0-9._:-]*
X-Request-IdResponseAlwaysThe 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.

HTTPCodeMeaning
400INVALID_REQUESTBody, query, path input, money, or an idempotency key is invalid. Unknown fields are rejected.
400INVALID_CURSORThe cursor is malformed or belongs to another resource.
400IDEMPOTENCY_KEY_REQUIREDAn idempotent mutation omitted its key.
401AUTHENTICATION_FAILEDThe Bearer credential is absent, malformed, revoked, or inactive.
403PERMISSION_DENIEDThe key lacks the exact operation grant, or its write authority changed before commit.
404NOT_FOUNDThe resource does not exist in this company scope. Cross-company resources also appear absent.
409CONFLICTState or idempotency input conflicts with an earlier request.
409INVENTORY_UNAVAILABLECheckout inventory is no longer available at the expected price.
409PRICING_UNAVAILABLEA quote cannot satisfy the quantity at one exact unit price.
409COMMISSION_RATE_UNAVAILABLENo explicit commission rate is effective. Contact Zentry onboarding or platform finance; company credentials cannot schedule one.
409INSUFFICIENT_FLOATThe prepaid float cannot fund the checkout.
409CHECKOUT_UNAVAILABLEThe company's checkout balances are absent, inactive, or inconsistent.
409COMPANY_VERIFICATION_REQUIREDThe company lacks a current KYB approval or an explicit rollout grace state. Complete or renew verification before checkout.
429RATE_LIMITEDThe request exceeded the current public limit.
500+INTERNAL_ERRORUnexpected 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 0 through 999999999999.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 date is YYYY-MM-DD; startsAt and endsAt are 24-hour HH:mm; timezone is an IANA name such as Africa/Lagos.

Idempotency

These operations require an Idempotency-Key:

  • POST /events
  • POST /events/{eventId}/inventory
  • POST /checkouts
  • POST /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.

  • limit defaults to 25 and must be from 1 through 100.
  • cursor is opaque, resource-specific, and at most 512 characters.
  • Send meta.page.nextCursor unchanged on the next request.
  • Stop when meta.page.hasMore is false; nextCursor is then null.
  • A cursor from /events is 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.

GrantPublic operations
COMPANY:event:readList and retrieve events
COMPANY:event:manageCreate, update, and cancel events
COMPANY:inventory:readRead inventory and request pricing quotes
COMPANY:inventory:manageCreate, update, and cancel inventory
COMPANY:checkout:createCreate an immediate prepaid-float checkout
COMPANY:order:readList and retrieve orders
COMPANY:ticket:validateValidate 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.

MethodPathGrantIdempotent keySuccess
POST/eventsevent:manageRequired201 event
GET/eventsevent:readNo200 event page
GET/events/{eventId}event:readNo200 event
PATCH/events/{eventId}event:manageNo200 event
DELETE/events/{eventId}event:manageNo200 cancelled event
GET/events/{eventId}/inventoryinventory:readNo200 inventory list
POST/events/{eventId}/inventoryinventory:manageRequired201 inventory tier
PATCH/events/{eventId}/inventory/{tier}inventory:manageNo200 inventory tier
DELETE/events/{eventId}/inventory/{tier}inventory:manageNo200 inventory tier
POST/pricing/quoteinventory:readNo200 pricing quote
POST/checkoutscheckout:createRequired201 order
GET/ordersorder:readNo200 order page
GET/orders/{orderId}order:readNo200 order
POST/events/{eventId}/admissions/{admissionId}/validateticket:validateRequired200 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"
}
MemberRequiredRules
titleYesNon-empty string, max 200
descriptionNoString, max 5,000; blank normalizes to null
dateYesReal calendar day in YYYY-MM-DD
startsAt, endsAtYesHH:mm
timezoneYesValid IANA timezone, max 100
location, venueNoString, max 500 each
imageUrlYesHTTPS URL, max 2,000
organizerNameNoString, max 200
categoryIdNoUUID v4 of an active category. Omit it unless one was supplied during onboarding; public v1 has no category catalogue.
statusYesDRAFT 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_ADMISSION
  • VIP
  • VVIP
  • EARLY_BIRD
  • REGULAR
  • STUDENT

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"
}
MemberRequiredRules
tierYesOne supported tier
titleYesNon-empty string, max 200
descriptionNoString, max 2,000
unitPriceYesMoney string
currencyYesNGN
isFreeYesMust agree with the price: free is exactly true plus "0", "0.0", or "0.00"
quantityYesInteger from 1 through 1,000
statusYesAVAILABLE 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:

  1. Zentry rechecks the active company, current KYB authority, API key, exact grant, event, and expected inventory price.
  2. The full gross is guarded-debited from the company's prepaid FLOAT balance.
  3. Commission is computed from the latest explicit effective rate and credited to the separate EARNINGS balance.
  4. 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: 250 means 2.5% (250 / 10,000); the inclusive maximum is 10000.
  • PERCENT: 2.5 means 2.5% (2.5 / 100); the inclusive maximum is 100.
  • FRACTION: 0.025 means 2.5%; the inclusive maximum is 1.

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.

QueryRequiredRules
limitNo1–100; default 25
cursorNoOpaque cursor from the preceding order page
eventIdNoUUID v4; restricts to one company event
statusNoComma-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:

OperationPayloadPublic 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.created
  • commission.accrued
  • balance.low
  • ticket.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.