Skip to content

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:

  1. 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.
  2. You receive the customer.created webhook with the customer id and the card’s billing metadata (bin, exp, last 4, brand) to store on your side.
  3. 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.

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:

paymentTyperequiresSubscriptionThis is a…Charged with
recurring—Plan — a subscription item billed every cyclePOST /v1/subscriptions (as an item)
one_timefalseStandalone product — a one-off purchase such as creditsPOST /v1/charges, or a customer-payment link (productCode)
one_timetrueAddon — a one-off charge that only makes sense on top of a planPOST /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.

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/links

Request body:

FieldTypeRequiredDescription
customer.namestringYesCustomer’s name (1-120 chars)
customer.emailstringNoCustomer’s email
isTestbooleanNoSandbox environment (default false)
redirectUrlstringNoURL to redirect the customer to after a successful capture
metadataobjectNoEchoed back in the customer.created webhook
Terminal window
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.

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.

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-links

Request body:

FieldTypeRequiredDescription
customer.namestringYesCustomer’s name (1-120 chars)
customer.emailstringNoCustomer’s email
conceptstringYesWhat is being paid (shown on the checkout)
amountintegerYesAmount 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
productCodestringYesCode 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)
isTestbooleanNoSandbox environment (default false)
redirectUrlstringNoURL to redirect the customer to after a successful payment
metadataobjectNoEchoed back in the payment.success and customer.created webhooks
Terminal window
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.

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 / month
POST /v1/subscriptions

Request body:

FieldTypeRequiredDescription
customerIdstringYesCustomer with a stored payment method
itemsarrayYes*1–20 items: { productCode, quantity? }. quantity defaults to 1 (max 1000)
productCodestringYes*Legacy single-product form — equivalent to items: [{ productCode, quantity: 1 }]
isTestbooleanNoSandbox 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 same currency (one invoice).
  • No duplicate productCodes — use quantity instead.
Terminal window
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:

CodeMeaning
ERR_PRODUCT_NOT_FOUNDA productCode does not exist in the project
ERR_PRODUCT_NOT_RECURRINGAn item is a one_time product — use charges instead
ERR_PRODUCT_NOT_ACTIVEAn item’s product is inactive or archived
ERR_MIXED_BILLING_PERIODSItems with different billingPeriod/billingInterval
ERR_MIXED_CURRENCIESItems with different currencies
ERR_CUSTOMER_NO_VAULTThe customer has no stored payment method

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/items
Terminal window
curl -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.

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/customer

Request body:

FieldTypeRequiredDescription
customerIdstringYesReplacement customer; must belong to the project and have a stored payment method
Terminal window
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.

Cancels a subscription either immediately or at the end of the current period.

PATCH /v1/subscriptions/:id/cancel

Request body:

FieldTypeRequiredDescription
cancelAtPeriodEndbooleanNofalse (default) cancels immediately; true schedules the cancellation for currentPeriodEnd
Terminal window
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 becomes cancelled right away and nextBillingAt is cleared. Emits subscription.cancelled with reason: "cancelled_by_request".
  • At period end (cancelAtPeriodEnd: true): the subscription stays active until currentPeriodEnd, then cancels.

Undoes a cancellation, in either of its two shapes:

  • Pending cancellation — the subscription is still active/past_due but 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 (currentPeriodEnd in the future). It returns to active and billing re-arms at currentPeriodEnd, 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/reactivate
Terminal window
curl -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.

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.

Returns the invoices (one per billed period) generated for a subscription.

GET /v1/subscriptions/:id/invoices

Query parameters: standard pagination (lim, off) plus an optional status filter (open, paid, failed, void).

Terminal window
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.

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/charges

Request body:

FieldTypeRequiredDescription
customerIdstringYesCustomer with a stored payment method
productCodestringYesCode of a one_time product in the project
quantityintegerNoUnits to charge (default 1, max 1000)
proratebooleanNoAddon products only: charge only the remaining fraction of the subscription’s current period (see Proration)
isTestbooleanNoSandbox environment (default false)
idempotencyKeystringNoRetries with the same key never charge twice
metadataobjectNoEchoed back in the charge.* webhook
Terminal window
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.

In the common case a charge resolves synchronously and the HTTP response already carries the final outcome:

statusHTTPTerminal?Meaning
paid201YesThe charge settled. externalChargeId is the processor’s reference.
failed201YesThe card was declined — see failureReason / failureCode.
processing202NoThe 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—YesOnly 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.
deferred201YesNMI 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"
}

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 paid or failed and the matching charge.succeeded / charge.failed webhook 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 called failed) and a charge.unknown webhook is emitted. That event is your signal that no further result is coming for this charge — stop waiting for charge.succeeded / charge.failed and 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 processing as a normal, expected outcome: persist the transactionId / orderId, show the charge as pending in your UI, and resolve the final result from the charge.succeeded / charge.failed webhook, 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 receive ERR_CHARGE_IN_PROGRESS while it is still settling — wait and retry, or check GET /v1/charges/:id).

Common errors:

CodeMeaning
ERR_PRODUCT_NOT_ONE_TIMEThe product is recurring — use subscriptions instead
ERR_CUSTOMER_NO_VAULTThe customer has no stored payment method
ERR_NO_ACTIVE_SUBSCRIPTIONAddon product, but the customer has no active subscription
ERR_PRORATE_NOT_ALLOWEDprorate: true on a product that does not require a subscription
ERR_SUBSCRIPTION_PERIOD_UNAVAILABLEThe active subscription has no usable period boundaries
ERR_PRORATED_AMOUNT_ZEROThe current period already ended — retry after renewal or charge without prorate
ERR_CHARGE_IN_PROGRESSA charge with this idempotency key is still settling (retry later)
ERR_AMOUNT_BELOW_NMI_MINIMUMNMI charge under $1.00 for a product that does not require a subscription (it cannot be deferred)
ERR_NO_DEFERRABLE_ITEMNMI addon charge under $1.00, but the addon is not an item of the subscription: add it with PATCH /v1/subscriptions/:id/items first

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 201 with status: "deferred", reason and deferredQuantity. deferredQuantity (amount / price, possibly fractional) is added to that item’s billed quantity, so the amount is collected on the next invoice. A charge.deferred webhook 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.

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.

GET /v1/charges
GET /v1/charges/:id

Query 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.

FieldTypeDescription
idstringCharge id: the transactionId of the POST /v1/charges response
orderIdstringOrder reference, as in the POST response and the charge.* webhooks
paymentStatusstringpending, processing, paid, failed, unknown or deferred (see Charge statuses)
transactionAmountintegerAmount in cents
transactionCurrencystringCurrency code, e.g. USD
quantityinteger|nullUnits of the product charged
projectIdstringProject of the charge
customerIdstringCustomer charged
productIdstring|nullProduct charged
subscriptionIdstring|nullSubscription the charge is linked to (addon charges)
invoiceIdstring|nullSubscription invoice, when the charge belongs to one
paymentLinkIdstring|nullCustomer-payment link the charge came from, if any
customerEmailstring|nullCustomer’s email at charge time
externalPaymentIdstring|nullProcessor’s reference (externalChargeId in the POST response)
paymentMethodstring|nulle.g. credit_card
paymentProviderstring|nullProcessor that ran the charge: nmi or emetec
paymentFailureReasonstring|nullProcessor’s decline text (failureReason in the POST response)
paidAtstring|nullISO-8601, when the charge was paid
failedAtstring|nullISO-8601, when the charge failed
createdAtstringISO-8601
updatedAtstringISO-8601
customerobjectOnly with expand=customer: a customer object
productobjectOnly 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 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/customers
POST /v1/customers
GET /v1/customers/:id
POST /v1/customers/:id/vault
  • GET /v1/customers: lim, off (pagination) and an optional status filter (active | inactive). Returns { customers, total }.
  • POST /v1/customers: body { name, email, phone? } (name 1–120 characters, email required, phone up 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:
FieldTypeRequiredDescription
paymentProviderstringNonmi or emetec (default nmi): the processor that owns the token; charges route to it
customerVaultIdstringYesThe processor’s vault id
cardLast4stringNoExactly 4 characters
cardBrandstringNoUp to 40 characters
cardBinstringNo6–8 digits
cardExpstringNoMMYY

Every endpoint that returns a customer (the customers endpoints, the subscription responses and expand=customer) uses the same fields:

FieldTypeDescription
idstringCustomer id
projectIdstringProject of the customer
namestringCustomer’s name
emailstringCustomer’s email
phonestring|nullCustomer’s phone
paymentProviderstring|nullProcessor that stores the card (nmi or emetec); null until a card is stored
cardLast4string|nullLast 4 digits of the stored card
cardBrandstring|nullBrand of the stored card
statusstringactive or inactive
metadatastring|nullReserved; currently null
createdAtstringISO-8601
updatedAtstringISO-8601
deletedAtstring|nullnull 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.

GET /v1/subscriptions
GET /v1/products
  • GET /v1/subscriptions: lim, off, and optional filters status (active | past_due | cancelled) and customerId. Add expand=items to include the configured items.
  • GET /v1/products: lim, off, and optional filters status (active | inactive | archived) and paymentType (one_time | recurring).

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.