REST API
The contract between your instance and the control plane. Every cent Payezz moves goes through here.
The charges route exists and compiles, but it's still missing its three database queries, so today it returns an error.
1. Authentication
Every route lives under https://api.payezz.app/v1/. Each instance has an instance_id and instance_secret pair, injected into the container as a secret during provisioning and rotatable from the control plane. You don't have to manage it: your instance already has it.
Every request is signed with HMAC-SHA256(secret, timestamp + "." + raw_body). What gets signed is the raw body, byte for byte, not the object serialized again: two different serializations of the same JSON produce different signatures.
POST /v1/payments/charges HTTP/1.1
Host: api.payezz.app
Content-Type: application/json
Payezz-Instance: inst_7f3a91c0
Payezz-Timestamp: 1789459200
Payezz-Signature: v1=6b1f...c39a
Idempotency-Key: renewal:svc_4821:2026-10
Payezz-Api-Version: 2026-09-01Verification rules that aren't negotiable:
- Constant-time comparison. Comparing secrets with a regular inequality operator leaks information through response timing.
- A clock window of plus or minus 300 seconds. Outside the window,
401. - A cache of signatures already seen within the window, to block replays.
Payezz-Api-Versionis required on every request.
2. Customers
POST /v1/customers creates or retrieves the customer on your connected account. The control plane stores the mapping between your ID and Stripe's, and the response always returns the current ID, even if the previous one was deleted.
// request
{
"external_id": "cli_918",
"email": "ana@example.com",
"name": "Ana Ruiz",
"metadata": { "instance_customer_id": "918" }
}
// response 200
{ "customer_id": "cus_QZ...", "created": true }3. On-session payments
POST /v1/payments/intents is a checkout payment: there's someone at the screen who can authenticate if their bank asks. It returns the client_secret your instance uses to render the payment form, plus the fee breakdown and the ID of the fee ledger entry.
// request
{
"customer_id": "cus_QZ...",
"amount_minor": 1499,
"currency": "eur",
"purpose": "new_order",
"reference": { "kind": "invoice", "id": "inv_2026_0417" },
"description": "Standard plan, October 2026",
"statement_descriptor_suffix": "ACME",
"metadata": { "order_id": "4821" },
"automatic_payment_methods": true,
"return_url": "https://store.acme.com/checkout/return"
}
// response 200
{
"payment_intent_id": "pi_3Q...",
"status": "requires_payment_method",
"client_secret": "pi_3Q..._secret_...",
"publishable_key": "pk_live_...",
"stripe_account": "acct_1Q...",
"fee": { "pct_bps": 190, "fixed_minor": 20,
"total_minor": 48, "currency": "eur" },
"ledger_entry_id": "cle_01J..."
}Accepted values for purpose are new_order, renewal, invoice, proration, reactivation, addon and manual_admin. It isn't decoration: it's what lets you reconcile later which part of your billing came from where.
4. Off-session charges
POST /v1/payments/charges is a renewal: it charges a saved payment method with nobody watching. It has three possible outcomes, and you have to handle all three.
// request
{
"customer_id": "cus_QZ...",
"payment_method_id": "pm_1Q...",
"amount_minor": 1499,
"currency": "eur",
"purpose": "renewal",
"reference": { "kind": "service", "id": "svc_4821" },
"description": "Renewal, Standard plan",
"metadata": { "service_id": "4821", "period": "2026-10" }
}
// 200, charged
{ "payment_intent_id": "pi_3Q...", "status": "succeeded",
"fee": { "total_minor": 48, "currency": "eur" },
"ledger_entry_id": "cle_01J..." }
// 200, the bank requires off-session authentication
{ "payment_intent_id": "pi_3Q...", "status": "requires_action",
"client_secret": "pi_3Q..._secret_...",
"recovery_url": null, "decline_code": null }
// 402, declined by the bank
{ "error": { "code": "card_declined",
"decline_code": "insufficient_funds",
"retryable": true, "retry_after_hint_days": 3,
"message_for_customer": "Your bank declined the payment." } }The requires_action case is the one most billing systems get wrong: they mark the service as unpaid and suspend it with no way out. Your instance should email the customer a link to a confirmation page that resumes that PaymentIntent. It isn't a failed payment, it's a payment halfway done.
5. Saved payment methods
POST /v1/payments/setup-intents to save a card, GET /v1/payments/methods to list them and POST /v1/payments/methods/{id}/detach to detach one. Always on the connected account, never on the platform.
6. Refunds
POST /v1/refunds supports full and partial refunds. The policy on how much of the fee is returned and how much is kept is applied by the control plane, not the instance, so it's the same for everyone and always on record.
// request
{ "payment_intent_id": "pi_3Q...", "amount_minor": 500,
"reason": "requested_by_customer", "note": "Billing error" }
// response
{ "refund_id": "re_3Q...", "status": "succeeded",
"commission": { "returned_minor": 10, "retained_minor": 20,
"policy": "pct_proporcional_fijo_retenido" } }7. Fees on payments outside the gateway
When you get paid by bank transfer, in cash or any other way that skips the gateway, and mark the invoice as paid, your instance first calls POST /v1/commissions/manual. The fee accrues, adds up and is settled at the end of the month.
// request
{
"reference": { "kind": "invoice", "id": "inv_2026_0418" },
"amount_minor": 2400,
"currency": "eur",
"method": "bank_transfer",
"occurred_at": "2026-10-03T09:12:00Z",
"external_ref": "Bank transfer 4409/2026"
}
// response
{ "ledger_entry_id": "cle_01J...", "status": "accrued",
"fee": { "total_minor": 66, "currency": "eur" },
"settles_in_period": "2026-10" }Values for method are bank_transfer, cash, paypal_external, crypto, other and admin_marked_paid. If the call fails, the invoice stays pending with a warning: your work isn't lost, but the record isn't skipped either.
8. Account status and fees
GET /v1/account returns the status of your connected account, and GET /v1/commissions/summary a summary of fees charged and accrued. These are the two calls behind the fees section of your own admin panel.
9. Idempotency
Every request that moves money carries an Idempotency-Key. The recommended format is <purpose>:<reference>:<period>, for example renewal:svc_4821:2026-10.
Never use today's date as part of the key. A key like renewal-4821-2026-10-03 means a retry the next day charges again, because the key changes. The billing period can be part of the key; the day of the attempt can't.
- The control plane stores
(instance_id, idempotency_key)with its response for seven days. Stripe only guarantees twenty-four hours; our window is longer on purpose, because a human retry after an incident shows up late. - The key sent to Stripe is derived with
hmac(instance_id, idempotency_key), so two instances can neither collide nor probe each other. - Reusing a key with a different body returns
409 idempotency_key_reuse, never a new charge.
10. Errors and retries
| Situation | Response | What your instance does |
|---|---|---|
| Invalid signature or clock outside the window | 401 | Don't retry. Raise an alert: it's a misconfiguration or a skewed clock. |
| Connected account without charges enabled | 409 account_not_ready | Don't retry. Notify the provider. |
| Currency not supported by the plan | 422 currency_not_supported | Don't retry. |
| Declined by the bank | 402 with retryable | Schedule according to your dunning policy. |
| Error on our side or Stripe's, or a timeout | 503 or no response | Retry with the same idempotency key, with exponential backoff, up to six times in twenty-four hours. |
| Rate limit | 429 with Retry-After | Honor the header. |
The rule that sums up all the others: a timeout is not a failed payment. The charge may have gone through. Your instance must never treat a 503 as “not charged”: retry with the same key and wait for the webhook.
Missing something you need for your integration? Email hola@payezz.app with your specific case.