Integration API (Projects)
The Integration API lets your software embed Zelta Pay as its payment engine. Everything is scoped to a project: each project has exactly one API key and one webhook.
The flow is:
- Collect the customer’s card once with a collection link (a hosted form served by your account’s active card processor — NMI or Emetec). Zelta Pay tokenizes the card and creates a customer — nothing is charged. The token records which processor stored it, so every later charge is routed back to that processor automatically.
- You receive the
customer.createdwebhook with the customer id and the card’s billing metadata (bin, exp, last 4, brand) to store on your side. - Your software drives the money movement via API: subscriptions (
POST /v1/subscriptions) for recurring plans — composed of one or more catalog products with quantities (items) — and charges (POST /v1/charges) for one-off products — independent products like credits, or addons that require an active subscription.
All endpoints require your project API key in the X-API-Key header. An account-level key gets 403 ERR_API_KEY_NOT_PROJECT_SCOPED on /v1/products, /v1/customers, /v1/subscriptions, /v1/collections and /v1/charges. The rate limit (60 requests per 60-second window) is shared by every /v1 endpoint called with the same key.
The project key also works on /v1/payment-links, limited to the links of the project: those created with the project’s key. Links created from the dashboard or with another project’s key are not listed, and reading or cancelling them returns 404 ERR_PAYMENT_LINK_NOT_FOUND.
Product types
Section titled “Product types”Every catalog product is defined by two fields — paymentType (one_time or recurring) and, for one-time products, requiresSubscription. Those two fields determine what a product is and how you charge it:
paymentType | requiresSubscription | This is a… | Charged with |
|---|---|---|---|
recurring | — | Plan — a subscription item billed every cycle | POST /v1/subscriptions (as an item) |
one_time | false | Standalone product — a one-off purchase such as credits | POST /v1/charges, or a customer-payment link (productCode) |
one_time | true | Addon — a one-off charge that only makes sense on top of a plan | POST /v1/charges — the customer must hold an active subscription in the project; the charge is linked to it (supports prorate) |
In other words, an addon is just a one_time product flagged with requiresSubscription: true: it cannot be charged on its own, only against a customer who already has an active subscription in the project. A one_time product without that flag is independent and can be charged at any time.
Create a collection link
Section titled “Create a collection link”Creates a hosted page that only collects the card: it is tokenized by your account’s active card processor (NMI or Emetec) and a customer is created. No product, no amount, no charge. Card-only: Yappy and PayPal cannot store a credential for later charges.
POST /v1/collections/linksRequest body:
| Field | Type | Required | Description |
|---|---|---|---|
customer.name | string | Yes | Customer’s name (1-120 chars) |
customer.email | string | No | Customer’s email |
isTest | boolean | No | Sandbox environment (default false) |
redirectUrl | string | No | URL to redirect the customer to after a successful capture |
metadata | object | No | Echoed back in the customer.created webhook |
curl -X POST https://api-pay.zelta.dev/v1/collections/links \ -H "X-API-Key: your-project-api-key" \ -H "Content-Type: application/json" \ -d '{ "customer": { "name": "John Doe", "email": "john@example.com" }, "redirectUrl": "https://your-app.com/billing/payment-method-saved", "metadata": { "userId": "usr_123" } }'Response (201):
{ "success": true, "data": { "id": "1234567890", "paymentLinkUrl": "https://pay.zelta.dev/AbCdEf123", "hashUrl": "AbCdEf123", "customerName": "John Doe", "customerEmail": "john@example.com", "status": "pending", "isTest": false, "expiresAt": "2026-06-07T00:00:00.000Z", "createdAt": "2026-06-04T00:00:00.000Z", "redirectUrl": "https://your-app.com/billing/payment-method-saved" }, "message": "Customer collection link created"}The link expires 3 days after it is created (expiresAt). This lifetime is fixed: expiresInDays is only available on one-time payment links.
Send the customer to paymentLinkUrl. When they save their card you receive the customer.created webhook on the project webhook — store customer.id and the card metadata, then drive transactions via API.
Open the link in a floating window (PayPal-style)
Section titled “Open the link in a floating window (PayPal-style)”A hosted link is a normal URL, so a page can’t force itself to open as a popup — the window is opened by your code. To keep the customer on your site (PayPal-style), open paymentLinkUrl with window.open instead of navigating away. The hosted page detects it was opened this way (window.opener), and on success it posts a message to your page and closes itself.
function openZeltaCheckout(paymentLinkUrl, onDone) { const w = 480; const h = 720; const left = window.screenX + (window.outerWidth - w) / 2; const top = window.screenY + (window.outerHeight - h) / 2; const popup = window.open( paymentLinkUrl, "zeltapay-checkout", `width=${w},height=${h},left=${left},top=${top},resizable,scrollbars`, );
function handleMessage(event) { // Only trust messages from the ZeltaPay hosted page. if (event.origin !== "https://pay.zelta.dev") return; const { type } = event.data || {}; if (type === "zeltapay:collected" || type === "zeltapay:paid") { window.removeEventListener("message", handleMessage); popup?.close(); onDone(event.data); } }
window.addEventListener("message", handleMessage);}The posted message is display-only and carries no customer data (no email, no card details):
{ "type": "zeltapay:collected", "status": "collected", "orderId": null}For a payment or customer-payment link it is:
{ "type": "zeltapay:paid", "status": "completed", "orderId": "ORD-12345-xyz"}type is zeltapay:collected for a collection link and zeltapay:paid for a payment/customer-payment link. The card data (brand, last 4…) comes from the customer.created webhook. The webhook remains the source of truth — treat this message only as a UI signal to close the window and refresh, and confirm the result from the customer.created / payment.success webhook. If the popup is blocked or opened as a normal tab, the page falls back to redirecting to redirectUrl.
Create a customer-payment link
Section titled “Create a customer-payment link”Like a collection link, but the hosted page charges amount in the same checkout as it stores the card and creates the customer. Use it for a first payment that also enrolls the customer for future charges (e.g. an initial purchase + a saved card for a subscription). Card-only — Yappy and PayPal cannot store a credential, so they are never offered on collection or customer-payment links (PayPal is available on one-time payment links only).
POST /v1/collections/payment-linksRequest body:
| Field | Type | Required | Description |
|---|---|---|---|
customer.name | string | Yes | Customer’s name (1-120 chars) |
customer.email | string | No | Customer’s email |
concept | string | Yes | What is being paid (shown on the checkout) |
amount | integer | Yes | Amount in cents (100–1,000,000, i.e. $1–$10,000). 0, negative or above 1,000,000 returns the 400 validation error; 1–99 returns 400 ERR_AMOUNT_BELOW_NMI_MINIMUM and sends a payment_link.rejected webhook to the project webhook |
productCode | string | Yes | Code of a one_time product in the project. The settled payment is recorded as a charge of this product against the new customer and a charge.succeeded webhook is emitted (after customer.created) |
isTest | boolean | No | Sandbox environment (default false) |
redirectUrl | string | No | URL to redirect the customer to after a successful payment |
metadata | object | No | Echoed back in the payment.success and customer.created webhooks |
curl -X POST https://api-pay.zelta.dev/v1/collections/payment-links \ -H "X-API-Key: your-project-api-key" \ -H "Content-Type: application/json" \ -d '{ "customer": { "name": "John Doe", "email": "john@example.com" }, "concept": "Plan Pro — primer mes", "amount": 2999, "productCode": "activacion", "redirectUrl": "https://your-app.com/billing/welcome", "metadata": { "userId": "usr_123" } }'Response (201):
{ "success": true, "data": { "id": "1234567890", "paymentLinkUrl": "https://pay.zelta.dev/AbCdEf123", "hashUrl": "AbCdEf123", "customerName": "John Doe", "customerEmail": "john@example.com", "concept": "Plan Pro — primer mes", "amount": 2999, "status": "pending", "isTest": false, "expiresAt": "2026-06-07T00:00:00.000Z", "createdAt": "2026-06-04T00:00:00.000Z", "redirectUrl": "https://your-app.com/billing/welcome" }, "message": "Customer payment link created"}Like collection links, it expires 3 days after it is created (expiresAt), and that lifetime is fixed.
Send the customer to paymentLinkUrl. On a successful checkout you receive both the payment.success webhook (for the charge) and the customer.created webhook (with customer.id and the stored card metadata) — store the customer and then drive further charges/subscriptions via API. If the payment is declined no customer is created.
The settled payment is recorded as a charge of productCode against the new customer (listed in GET /v1/charges and in the project’s charges view) and a charge.succeeded webhook is also emitted after customer.created, with the same shape as an API-driven charge — so the first payment reconciles through the exact same path as your later POST /v1/charges calls. This product linkage is what distinguishes integration-driven payments from independent one-time payment links, which carry no product.
Create a subscription
Section titled “Create a subscription”Subscribes a vaulted customer to one or more recurring products (“items”), each with a quantity. Every cycle the invoice is Σ(product.price × quantity) over the items, with the catalog prices current at billing time — editing a product’s price in the dashboard changes the next invoice, not the one already issued.
This is how software with per-tenant dynamic pricing composes its monthly total from a fixed dashboard catalog instead of creating throwaway products. Example: a POS plan of $35 base + $20 per extra branch + $5 per extra user, for a tenant with 2 branches and 8 users:
items: [ { "productCode": "pos-base", "quantity": 1 }, // $35.00 { "productCode": "pos-sucursal", "quantity": 1 }, // $20.00 { "productCode": "pos-usuario", "quantity": 2 } // $10.00] → bills $65.00 / monthPOST /v1/subscriptionsRequest body:
| Field | Type | Required | Description |
|---|---|---|---|
customerId | string | Yes | Customer with a stored payment method |
items | array | Yes* | 1–20 items: { productCode, quantity? }. quantity defaults to 1 (max 1000) |
productCode | string | Yes* | Legacy single-product form — equivalent to items: [{ productCode, quantity: 1 }] |
isTest | boolean | No | Sandbox environment (default false) |
* Pass either items or productCode, not both.
Rules over the item set:
- Every product must be
recurring, active, and belong to the project. - All items must share the same
billingPeriod/billingInterval(one cycle per subscription) and the samecurrency(one invoice). - No duplicate
productCodes — usequantityinstead.
curl -X POST https://api-pay.zelta.dev/v1/subscriptions \ -H "X-API-Key: your-project-api-key" \ -H "Content-Type: application/json" \ -d '{ "customerId": "1234567890", "items": [ { "productCode": "pos-base" }, { "productCode": "pos-sucursal" }, { "productCode": "pos-usuario", "quantity": 2 } ] }'Response (201):
{ "success": true, "data": { "subscription": { "id": "9876543210", "customerId": "1234567890", "status": "active", "currentPeriodStart": "2026-06-04T00:00:00.000Z", "currentPeriodEnd": "2026-07-04T00:00:00.000Z", "nextBillingAt": "2026-07-04T00:00:00.000Z", "items": [ { "productId": "111", "quantity": 1, "effectiveFrom": "2026-06-04T00:00:00.000Z" }, { "productId": "222", "quantity": 1, "effectiveFrom": "2026-06-04T00:00:00.000Z" }, { "productId": "333", "quantity": 2, "effectiveFrom": "2026-06-04T00:00:00.000Z" } ], "cycleTotal": 6500 } }, "message": "Subscription created"}A subscription.created webhook is emitted with the items (productCode, name, price, quantity) and the cycleTotal. Recurring invoices then emit invoice.paid / invoice.payment_failed with the same item breakdown.
GET /v1/subscriptions/:id always returns the configured items (each with its nested product) plus cycleTotal; on GET /v1/subscriptions add expand=items. It also returns the subscription’s customer as a customer object, as do the PATCH /v1/subscriptions/:id/items and PATCH /v1/subscriptions/:id/customer responses (GET /v1/subscriptions includes it with expand=customer).
Common errors:
| Code | Meaning |
|---|---|
ERR_PRODUCT_NOT_FOUND | A productCode does not exist in the project |
ERR_PRODUCT_NOT_RECURRING | An item is a one_time product — use charges instead |
ERR_PRODUCT_NOT_ACTIVE | An item’s product is inactive or archived |
ERR_MIXED_BILLING_PERIODS | Items with different billingPeriod/billingInterval |
ERR_MIXED_CURRENCIES | Items with different currencies |
ERR_CUSTOMER_NO_VAULT | The customer has no stored payment method |
Update subscription items
Section titled “Update subscription items”Replaces the subscription’s whole item set (same rules as on create, and the new set must keep the subscription’s current billingPeriod/billingInterval — to change cadence, cancel and create a new subscription).
PATCH /v1/subscriptions/:id/itemscurl -X PATCH https://api-pay.zelta.dev/v1/subscriptions/9876543210/items \ -H "X-API-Key: your-project-api-key" \ -H "Content-Type: application/json" \ -d '{ "items": [ { "productCode": "pos-base" }, { "productCode": "pos-sucursal", "quantity": 2 }, { "productCode": "pos-usuario", "quantity": 2 } ] }'Allowed while the subscription is active or past_due. A subscription.items_updated webhook is emitted with the new items, cycleTotal and effectiveAt.
Change the subscription customer
Section titled “Change the subscription customer”Re-points the subscription to another vaulted customer of the same project. Since one customer holds exactly one stored card, this is how the integrating software switches the active payment method of a recurring plan without cancel + recreate (which would shift the billing anchor) and without charging anything.
PATCH /v1/subscriptions/:id/customerRequest body:
| Field | Type | Required | Description |
|---|---|---|---|
customerId | string | Yes | Replacement customer; must belong to the project and have a stored payment method |
curl -X PATCH https://api-pay.zelta.dev/v1/subscriptions/9876543210/customer \ -H "X-API-Key: your-project-api-key" \ -H "Content-Type: application/json" \ -d '{ "customerId": "1234567890" }'Allowed while the subscription is active or past_due (the latter is the dunning-rescue case: switch to a working card and let the retries recover the cycle). A subscription.customer_updated webhook is emitted with the new and previous customer ids. Fails with ERR_CUSTOMER_NO_VAULT if the replacement customer has no stored payment method.
Cancel a subscription
Section titled “Cancel a subscription”Cancels a subscription either immediately or at the end of the current period.
PATCH /v1/subscriptions/:id/cancelRequest body:
| Field | Type | Required | Description |
|---|---|---|---|
cancelAtPeriodEnd | boolean | No | false (default) cancels immediately; true schedules the cancellation for currentPeriodEnd |
curl -X PATCH https://api-pay.zelta.dev/v1/subscriptions/9876543210/cancel \ -H "X-API-Key: your-project-api-key" \ -H "Content-Type: application/json" \ -d '{ "cancelAtPeriodEnd": true }'- Immediate (
cancelAtPeriodEnd: false/ omitted): the subscription becomescancelledright away andnextBillingAtis cleared. Emitssubscription.cancelledwithreason: "cancelled_by_request". - At period end (
cancelAtPeriodEnd: true): the subscription staysactiveuntilcurrentPeriodEnd, then cancels.
Reactivate a subscription
Section titled “Reactivate a subscription”Undoes a cancellation, in either of its two shapes:
- Pending cancellation — the subscription is still
active/past_duebut scheduled to cancel at period end (cancelAtPeriodEnd: true). Reactivating simply lifts that scheduled cancellation; billing continues untouched. - Already cancelled (
status: "cancelled") — allowed only while still inside the period the customer already paid for (currentPeriodEndin the future). It returns toactiveand billing re-arms atcurrentPeriodEnd, so the sweep picks it up again from the next boundary and the in-course (already-paid) period is never charged twice.
PATCH /v1/subscriptions/:id/reactivatecurl -X PATCH https://api-pay.zelta.dev/v1/subscriptions/9876543210/reactivate \ -H "X-API-Key: your-project-api-key"Emits subscription.resumed. Fails with ERR_INVALID_SUBSCRIPTION_TRANSITION if the subscription is active with no pending cancellation, and with ERR_SUBSCRIPTION_PERIOD_ELAPSED if it is cancelled and the paid period has already lapsed — past that point the period is over and a new subscription must be created.
Dunning (failed renewals)
Section titled “Dunning (failed renewals)”When a renewal charge is declined, the subscription enters past_due and the charge is retried automatically on a fixed schedule — the original attempt plus four retries on days +1, +3, +5 and +7 from the original due date (a 7-day grace window). Each retry emits invoice.payment_failed and moves subscription.past_due forward with the next nextBillingAt. If the day-7 retry also fails, the subscription is cancelled and emits subscription.cancelled with reason: "dunning_exhausted".
To rescue a past_due subscription, change its customer to one with a working card — the already-scheduled retries will charge the new vault.
List a subscription’s invoices
Section titled “List a subscription’s invoices”Returns the invoices (one per billed period) generated for a subscription.
GET /v1/subscriptions/:id/invoicesQuery parameters: standard pagination (lim, off) plus an optional status filter (open, paid, failed, void).
curl "https://api-pay.zelta.dev/v1/subscriptions/9876543210/invoices?status=paid" \ -H "X-API-Key: your-project-api-key"Each invoice carries its amount, currency, status, periodStart/periodEnd, attempts, and an itemsSnapshot frozen at billing time so the charged composition is preserved even if the catalog later changes.
Create a charge
Section titled “Create a charge”Charges a vaulted customer for a one_time product. The amount is always product.price × quantity.
- Independent products (e.g. credits): charged directly.
- Addon products (
requiresSubscription: true): the customer must hold an active subscription in the project; the charge is linked to it.
POST /v1/chargesRequest body:
| Field | Type | Required | Description |
|---|---|---|---|
customerId | string | Yes | Customer with a stored payment method |
productCode | string | Yes | Code of a one_time product in the project |
quantity | integer | No | Units to charge (default 1, max 1000) |
prorate | boolean | No | Addon products only: charge only the remaining fraction of the subscription’s current period (see Proration) |
isTest | boolean | No | Sandbox environment (default false) |
idempotencyKey | string | No | Retries with the same key never charge twice |
metadata | object | No | Echoed back in the charge.* webhook |
curl -X POST https://api-pay.zelta.dev/v1/charges \ -H "X-API-Key: your-project-api-key" \ -H "Content-Type: application/json" \ -d '{ "customerId": "1234567890", "productCode": "prod_credits100", "quantity": 2, "idempotencyKey": "order-789" }'Response (201):
{ "success": true, "data": { "charge": { "status": "paid", "transactionId": "9876543210", "orderId": "ORD-12345-abc", "amount": 2000, "currency": "USD", "quantity": 2, "prorated": false, "externalChargeId": "ext_456" } }, "message": "Charge succeeded"}amount is the amount actually charged in cents (relevant with prorate). Declines return "status": "failed" with failureReason (the processor’s text) and, when the decline maps to a known cause, a normalized failureCode — currently duplicate_transaction, meaning the gateway’s duplicate-transaction window rejected a same-amount repeat and the card itself was not declined. A charge.succeeded or charge.failed webhook is also emitted to the project webhook with the same fields.
Charge statuses
Section titled “Charge statuses”In the common case a charge resolves synchronously and the HTTP response already carries the final outcome:
status | HTTP | Terminal? | Meaning |
|---|---|---|---|
paid | 201 | Yes | The charge settled. externalChargeId is the processor’s reference. |
failed | 201 | Yes | The card was declined — see failureReason / failureCode. |
processing | 202 | No | The synchronous attempt timed out inconclusively and Zelta Pay is now reconciling the charge against the gateway in the background. This is not a failure — the final result is delivered by webhook (see below). |
unknown | — | Yes | Only reachable from processing: the reconciliation window elapsed without a definitive gateway answer. The charge may or may not have been captured, so it is flagged for manual review — never treat it as a clean failure. A charge.unknown webhook is emitted so you know no further result is coming. |
deferred | 201 | Yes | NMI charge under $1.00 for an addon that is an item of the customer’s active subscription: approved without moving money and billed on the subscription’s next invoice (message "Charge approved and deferred to the subscription's next invoice", charge.deferred webhook). See Deferred charges. |
A 201 always means the verdict is final. A 202 processing means you must wait for the charge.succeeded / charge.failed webhook (or poll GET /v1/charges/:id) for the result:
{ "success": true, "data": { "charge": { "status": "processing", "transactionId": "9876543210", "orderId": "ORD-12345-abc", "amount": 2000, "currency": "USD", "quantity": 2, "prorated": false } }, "message": "Charge is processing; the final result will be delivered via webhook"}Timeouts and how long to wait
Section titled “Timeouts and how long to wait”Zelta Pay caps every call to the card processor at 25 seconds. Charges normally settle well within that, so POST /v1/charges returns 201 with paid or failed in a single round trip.
If the processor does not answer within those 25s and an immediate status query is still inconclusive, the request returns 202 with status: "processing" instead of hanging or guessing. A durable background job then polls the gateway on a backoff for up to ~2 minutes and settles the charge:
- As soon as the gateway gives a final answer the charge becomes
paidorfailedand the matchingcharge.succeeded/charge.failedwebhook fires — exactly as a synchronous settle would. - If the full ~2-minute window passes with no definitive answer, the charge is marked
unknown(the money may have actually moved, so it is not calledfailed) and acharge.unknownwebhook is emitted. That event is your signal that no further result is coming for this charge — stop waiting forcharge.succeeded/charge.failedand flag it for manual review.
Recommendations for your HTTP client:
- Set your request timeout to at least 30 seconds (we suggest 60s) so a slow-but-successful synchronous charge is never aborted on your side.
- Treat
202 processingas a normal, expected outcome: persist thetransactionId/orderId, show the charge as pending in your UI, and resolve the final result from thecharge.succeeded/charge.failedwebhook, not from the HTTP response. - Always send an
idempotencyKey. If your own client times out before Zelta Pay responds, retrying with the same key safely returns the in-flight charge instead of charging twice (you may receiveERR_CHARGE_IN_PROGRESSwhile it is still settling — wait and retry, or checkGET /v1/charges/:id).
Common errors:
| Code | Meaning |
|---|---|
ERR_PRODUCT_NOT_ONE_TIME | The product is recurring — use subscriptions instead |
ERR_CUSTOMER_NO_VAULT | The customer has no stored payment method |
ERR_NO_ACTIVE_SUBSCRIPTION | Addon product, but the customer has no active subscription |
ERR_PRORATE_NOT_ALLOWED | prorate: true on a product that does not require a subscription |
ERR_SUBSCRIPTION_PERIOD_UNAVAILABLE | The active subscription has no usable period boundaries |
ERR_PRORATED_AMOUNT_ZERO | The current period already ended — retry after renewal or charge without prorate |
ERR_CHARGE_IN_PROGRESS | A charge with this idempotency key is still settling (retry later) |
ERR_AMOUNT_BELOW_NMI_MINIMUM | NMI charge under $1.00 for a product that does not require a subscription (it cannot be deferred) |
ERR_NO_DEFERRABLE_ITEM | NMI addon charge under $1.00, but the addon is not an item of the subscription: add it with PATCH /v1/subscriptions/:id/items first |
Deferred charges
Section titled “Deferred charges”NMI rejects any charge under $1.00. When a POST /v1/charges on NMI comes to less than 100 cents (typically a prorated addon near the end of a period), Zelta Pay does not send it to the gateway:
- If the product is an addon and it is an item of the customer’s active subscription, the charge is approved without moving money and returns
201withstatus: "deferred",reasonanddeferredQuantity.deferredQuantity(amount / price, possibly fractional) is added to that item’s billed quantity, so the amount is collected on the next invoice. Acharge.deferredwebhook is emitted. - If the addon is not an item of the subscription, the request fails with
ERR_NO_DEFERRABLE_ITEM. - If the product does not require a subscription, the request fails with
ERR_AMOUNT_BELOW_NMI_MINIMUM.
Emetec has no minimum, so it charges normally.
Proration
Section titled “Proration”For addon products you can pass prorate: true so the customer only pays for the remainder of their current billing period:
amount = round(price × quantity × remainingFraction)remainingFraction = (currentPeriodEnd − now) / (currentPeriodEnd − currentPeriodStart)- The fraction is computed against the active subscription the charge is linked to, and clamped to
[0, 1]. - Example: a $10.00 addon bought halfway through a 30-day cycle charges $5.00 (
"amount": 500, "prorated": true). - Right after a renewal the fraction is ~1 (full price); at the very end of the period it approaches 0 — if it rounds to zero the request is rejected with
ERR_PRORATED_AMOUNT_ZERO. - The proration is a one-time adjustment: the addon is not added to future invoices and does not renew.
The charge.succeeded webhook carries the real amount and "prorated": true so your software can reconcile.
List charges
Section titled “List charges”GET /v1/chargesGET /v1/charges/:idQuery parameters for the list endpoint: lim, off (pagination), paymentStatus (pending | processing | paid | failed | unknown | deferred — see Charge statuses), customerId, and expand=customer,product. Any other expand value returns 400 ERR_INVALID_RELATIONS.
The list returns { charges, total } and GET /v1/charges/:id returns { charge }.
Polling GET /v1/charges/:id is the way to read the current status of a charge that returned 202 processing, if you prefer it over waiting for the webhook.
The charge object
Section titled “The charge object”| Field | Type | Description |
|---|---|---|
id | string | Charge id: the transactionId of the POST /v1/charges response |
orderId | string | Order reference, as in the POST response and the charge.* webhooks |
paymentStatus | string | pending, processing, paid, failed, unknown or deferred (see Charge statuses) |
transactionAmount | integer | Amount in cents |
transactionCurrency | string | Currency code, e.g. USD |
quantity | integer|null | Units of the product charged |
projectId | string | Project of the charge |
customerId | string | Customer charged |
productId | string|null | Product charged |
subscriptionId | string|null | Subscription the charge is linked to (addon charges) |
invoiceId | string|null | Subscription invoice, when the charge belongs to one |
paymentLinkId | string|null | Customer-payment link the charge came from, if any |
customerEmail | string|null | Customer’s email at charge time |
externalPaymentId | string|null | Processor’s reference (externalChargeId in the POST response) |
paymentMethod | string|null | e.g. credit_card |
paymentProvider | string|null | Processor that ran the charge: nmi or emetec |
paymentFailureReason | string|null | Processor’s decline text (failureReason in the POST response) |
paidAt | string|null | ISO-8601, when the charge was paid |
failedAt | string|null | ISO-8601, when the charge failed |
createdAt | string | ISO-8601 |
updatedAt | string | ISO-8601 |
customer | object | Only with expand=customer: a customer object |
product | object | Only with expand=product: the catalog product |
{ "success": true, "data": { "charge": { "id": "9876543210", "orderId": "ORD-12345-abc", "paymentStatus": "paid", "transactionAmount": 2000, "transactionCurrency": "USD", "quantity": 2, "projectId": "5550001111", "customerId": "1234567890", "productId": "4443332221", "subscriptionId": null, "invoiceId": null, "paymentLinkId": null, "customerEmail": "john@example.com", "externalPaymentId": "ext_456", "paymentMethod": "credit_card", "paymentProvider": "nmi", "paymentFailureReason": null, "paidAt": "2026-06-04T15:30:00.000Z", "failedAt": null, "createdAt": "2026-06-04T15:29:58.000Z", "updatedAt": "2026-06-04T15:30:00.000Z" } }, "message": "Operation completed successfully"}Customers
Section titled “Customers”Customers are normally created by a collection link or a customer-payment link. The customers endpoints let you list and read them, and create a customer yourself when your software vaults the card on its own.
GET /v1/customersPOST /v1/customersGET /v1/customers/:idPOST /v1/customers/:id/vaultGET /v1/customers:lim,off(pagination) and an optionalstatusfilter (active|inactive). Returns{ customers, total }.POST /v1/customers: body{ name, email, phone? }(name1–120 characters,emailrequired,phoneup to 40 characters). Creates a customer with no stored card.GET /v1/customers/:id: one customer of the project.POST /v1/customers/:id/vault: attaches a card-processor vault id that your software obtained itself. It answers with the updated customer object, which does not repeat the vault id, BIN or expiry. Body:
| Field | Type | Required | Description |
|---|---|---|---|
paymentProvider | string | No | nmi or emetec (default nmi): the processor that owns the token; charges route to it |
customerVaultId | string | Yes | The processor’s vault id |
cardLast4 | string | No | Exactly 4 characters |
cardBrand | string | No | Up to 40 characters |
cardBin | string | No | 6–8 digits |
cardExp | string | No | MMYY |
The customer object
Section titled “The customer object”Every endpoint that returns a customer (the customers endpoints, the subscription responses and expand=customer) uses the same fields:
| Field | Type | Description |
|---|---|---|
id | string | Customer id |
projectId | string | Project of the customer |
name | string | Customer’s name |
email | string | Customer’s email |
phone | string|null | Customer’s phone |
paymentProvider | string|null | Processor that stores the card (nmi or emetec); null until a card is stored |
cardLast4 | string|null | Last 4 digits of the stored card |
cardBrand | string|null | Brand of the stored card |
status | string | active or inactive |
metadata | string|null | Reserved; currently null |
createdAt | string | ISO-8601 |
updatedAt | string | ISO-8601 |
deletedAt | string|null | null for a live customer |
The stored-card token and the card’s BIN and expiry are not part of the customer object. The BIN and expiry are delivered once, in the customer.created webhook (customer.card.bin and customer.card.exp): store them on your side if you need them.
List subscriptions and products
Section titled “List subscriptions and products”GET /v1/subscriptionsGET /v1/productsGET /v1/subscriptions:lim,off, and optional filtersstatus(active|past_due|cancelled) andcustomerId. Addexpand=itemsto include the configured items.GET /v1/products:lim,off, and optional filtersstatus(active|inactive|archived) andpaymentType(one_time|recurring).
Idempotency
Section titled “Idempotency”Pass idempotencyKey on POST /v1/charges to make retries safe: if the same key is sent twice, the second request returns the result of the first charge instead of charging again. Keys are namespaced per project.