Documentation

REST API

The contract between your instance and the control plane. Every cent Payezz moves goes through here.

Partial

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

Verification 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-Version is 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

SituationResponseWhat your instance does
Invalid signature or clock outside the window401Don't retry. Raise an alert: it's a misconfiguration or a skewed clock.
Connected account without charges enabled409 account_not_readyDon't retry. Notify the provider.
Currency not supported by the plan422 currency_not_supportedDon't retry.
Declined by the bank402 with retryableSchedule according to your dunning policy.
Error on our side or Stripe's, or a timeout503 or no responseRetry with the same idempotency key, with exponential backoff, up to six times in twenty-four hours.
Rate limit429 with Retry-AfterHonor 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.