Skip to content

Collect Payments with Tap

Connect Tap Payments to Invora Billing and collect money from your customers through a Tap-hosted checkout page. This guide covers connecting your Tap account, generating a payment link for an invoice, bundling invoices into a payment request, charging saved cards, and reacting to payment outcomes through webhooks.

Tap is the default gateway for the MENA region (cards, Apple Pay, mada, KNET, benefit). For the full list of supported gateways see Billing & subscriptions.

All examples use the REST/JSON surface (gRPC-JSON transcoding): JSON bodies, camelCase field names, and a bearer token from the Authentication guide. Base URLs:

Environment Base URL
Production https://gateway.invora.app
Staging https://stg-gateway.invora.app

Set a token once and reuse it in every example below:

export TOKEN="<your-access-token>"   # see the Authentication guide

How Payment Collection Works

Before step 1, run the readiness check — one call, six possible answers, and (for three of them — 403, 400, 503) it tells you exactly which precondition you are missing, without creating anything. (200 names none missing; 401 is a token-format problem, not one of the four preconditions; 500 says outright it isn't one of them either — see the table under Readiness check.)

Steps 1–3 below each match one numbered ## section by the same number. Step 4 ("Collect") is where the mapping stops being 1:1 — it covers two sections (## 4 and ## 5, which are alternatives, not a sequence — pick one). Steps 5 and 6 aren't API calls at all: "the customer pays" happens on Tap's own checkout page, and "Invora reconciles" is described under Webhook Events, not a numbered section.

  1. Connect Tap once — register your Tap secret key as a payment provider on your tenant. See Connect Tap.
  2. Have a billing customer — create one, or read an existing one back by the externalId you gave it (List is currently decommissioned — see the note in Create or find the billing customer). Every later step is keyed on the customer's id, which on the deployed backend is that same externalId.
  3. Link each billing customer to Tap — call CustomersService.Update with paymentProvider: "PROVIDER_TYPE_TAP" and syncWithProvider: true. This creates the provider-customer record that GetPaymentUrl requires. This step is mandatory — skipping it returns 400 no_linked_payment_provider. See Link the customer to Tap.
  4. Collect — generate a payment link for a finalized invoice, or send a payment request that bundles one or more invoices. These are two alternative ways to collect on the same invoice(s) — don't do both.
  5. The customer pays on the Tap-hosted checkout page (card entry + 3D Secure handled by Tap). Not a separate API call.
  6. Invora reconciles the result and emits billing webhook events (payment.succeeded, invoice.payment_status_updated, …) to your registered endpoint. See Webhook Events.
flowchart LR
    R[Readiness check] --> A[Connect Tap<br/>once per tenant]
    A --> N[Create or find<br/>the billing customer]
    N --> B[Link customer to Tap<br/>once per customer]
    B --> C[Finalize invoice]
    C --> D{Collect}
    D -->|single invoice| E[Get payment URL]
    D -->|one or more invoices| F[Create payment request]
    E --> G[Customer pays<br/>on Tap checkout]
    F --> G
    G --> H[Invora reconciles]
    H --> I[Webhook events<br/>to your endpoint]

Prerequisites

Four things must all be true before any call on this page works. They fail in a fixed order, and each one fails with a different status — so the fastest way to find out where you stand is the readiness check below, not reading this list.

  1. Your token resolves a tenant. The token must carry the Invora project audience and urn:zitadel:iam:user:resourceowner so the platform can resolve your organization to a tenant — see Authentication. Without a resolvable tenant you get an empty-bodied 503, not a billing error.
  2. The calling principal holds the Invora.Billing permission. Every billing RPC on this page is gated on it. The onboarding flow grants it to the user who completes the business profile — but it is not automatic for every credential. A machine user, service account, or custom OIDC application provisioned outside that flow (which is what most integrations use) holds only the roles it was explicitly granted, and calls the billing surface with PERMISSION_DENIED until Invora.Billing is added to its grant.
  3. Billing is provisioned for your tenant. A tenant can be fully onboarded — issuing invoices, listing documents — and still have no billing configuration, because the billing customer is provisioned on a best-effort path during registration that does not fail registration when it fails itself. A tenant in that state answers billing calls with FAILED_PRECONDITION and the message No billing configuration exists for tenant '<tenant-id>'. That is not something you can fix from the API — mail support@invora.app with the tenant id from the message.
  4. A billing customer exists and is linked to a payment provider. Steps 2 and 3 below do this. Until step 3 is done, GetPaymentUrl and the checkout/portal URLs fail with no_linked_payment_provider.

You also need a Tap secret key from the Tap dashboard:

  • Sandbox (test): sk_test_…
  • Production (live): sk_live_…

Readiness check

Run this first. It is the narrowest billing call there is, so an error response tells you which of the first three preconditions you are missing — without creating anything. It cannot tell you about precondition 4 (the payment-provider link); see the 200 row below.

curl -s -o /tmp/invora-readiness.json \
  -w 'HTTP %{http_code}\n' \
  -X POST https://gateway.invora.app/api/billing/v2/customers/list \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
cat /tmp/invora-readiness.json
What you get back What it means What to do
200 with an items array — the contract shape; not reachable while List is decommissioned, see the 500 row Preconditions 1–3 hold (token resolves a tenant, the principal holds Invora.Billing, billing is provisioned). It says nothing about precondition 4 — List never looks at payment-provider linkage, so a 200 here is consistent with every customer being unlinked. If GetPaymentUrl still fails with no_linked_payment_provider after a 200 here, that's expected — go do step 3, don't re-run this check. Continue to step 1.
401 + {"code": 16, …} A token was presented and is malformed or expired. (An absent header gives 403, not this — see the next row.) Re-mint it — see Authentication.
403 + {"code": 7, "message": "المستخدم غير مصرح له", …} Precondition 2: the principal does not hold Invora.Billing. Also what you get when the Authorization header is absent entirely — check you actually sent it before assuming a role problem. Grant Invora.Billing to the credential, or use one that has it.
400 + {"code": 9, "message": "No billing configuration exists for tenant '…'"} Precondition 3: the tenant was never provisioned for billing. Mail support@invora.app with that tenant id.
503 with no body at all Precondition 1: Invora could not resolve your tenant. Nothing to do with Tap. See 503 with an empty body.
500 + "NotImplementedError: CustomersQuery is served by Invora PartiesService.List…" Not one of the four preconditions — a known, unrelated backend migration state (the customer→party decouple has retired this specific List RPC on the currently deployed backend). Every check above it (1–3) already ran and passed before this error, or you'd have gotten one of the rows above instead. Harmless for this guide's purpose — proceed to step 1. Don't file a support ticket for this specific error.

The 403 body is Arabic-only, in every environment

PERMISSION_DENIED responses carry the Arabic string المستخدم غير مصرح له ("User is not authorized") regardless of Accept-Language or x-invora-culture. The English text exists server-side but is not what gets serialized. Match on the numeric code (7), never on message. And note that 403 means the caller lacks the permission — it is not the "tenant not provisioned" answer, which is a separate 400 FAILED_PRECONDITION. See Three ways a billing call fails for the full picture.

Never send a live key to a non-production environment

Use sk_test_… keys against stg-gateway.invora.app. A live sk_live_… key on staging will attempt real charges.

The try-it console pre-fills Tap's shared test key

The interactive Send buttons in these docs default the Tap key field to Tap's own published sandbox key (from its test-keys page) — it is shared across everyone reading Tap's docs, so it's fine for a quick try but prefer your own test key from the Tap dashboard for anything real.

1. Connect Tap

Register your Tap credentials. This creates a provider record used for every later payment operation. Save the returned id.

curl -X POST https://gateway.invora.app/api/billing/v2/integrations/tap \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "apiKey": "sk_test_YOUR_TAP_KEY",
    "code": "tap_main",
    "name": "Tap Payments",
    "successRedirectUrl": "https://your-app.com/payment/success",
    "supports3ds": true,
    "saveCardEnabled": false
  }'
Response
{
  "tapProvider": {
    "id": "7705b5eb-30eb-4ca4-ba90-4478c04ff5b4",
    "code": "tap_main",
    "name": "Tap Payments",
    "successRedirectUrl": "https://your-app.com/payment/success"
  }
}
Field Required Description
apiKey ✓ Your Tap secret key. Write-only — it is never returned in responses.
code ✓ Unique slug for this provider within your tenant (e.g. tap_main).
name ✓ Display name.
successRedirectUrl Where the customer's browser returns after the Tap checkout completes or is abandoned.
supports3ds Enable 3D Secure on customer-initiated charges. Defaults to true.
saveCardEnabled Store the card for future charges. Requires Tap KYC approval — see Saved cards.

Update the provider

Change the redirect URL or toggles later. The provider id is a path parameter; send only the fields you want to change.

curl -X PUT https://gateway.invora.app/api/billing/v2/integrations/tap/7705b5eb-30eb-4ca4-ba90-4478c04ff5b4 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "successRedirectUrl": "https://your-app.com/payment/thank-you" }'

The API key cannot be changed

The secret key is write-once. To rotate it, delete the provider and create a new one.

2. Create or Find the Billing Customer

Every step after this one is keyed on the customer's id.

id and externalId carry the same value

The customer→party decouple (LAGO-2 #11) made the party key the single customer identifier, so the id the API returns is the externalId you supplied: Create echoes the same string back in both fields, and GET /api/billing/v2/customers/<that externalId> returns the customer with 200.

So the {id} path segment on Get, Update, GetCheckoutUrl and GetCustomerPortalUrl takes the externalId you chose, and PaymentRequestService.Create's externalCustomerId body field takes the same value. A key that does not exist answers 404 NOT_FOUND — Party '<key>' not found in Invora.

Create a customer

country and currency are protobuf enums — send the full value name, never the ISO code

CreateRequest.country is invora.billing.common.v2.CountryCode and CreateRequest.currency is CurrencyEnum. proto3 JSON accepts only the enum value name (or its integer), so the bare ISO forms are rejected with INVALID_ARGUMENT:

You want Send Not
Saudi Arabia "COUNTRY_CODE_SA" "SA", "sa", "SAU", "Saudi Arabia"
Egypt "COUNTRY_CODE_EG" "EG"
Saudi riyal "CURRENCY_ENUM_SAR" "SAR"

The pattern is the ISO 3166-1 alpha-2 code (or ISO 4217 currency code) upper-cased and prefixed with the enum name — COUNTRY_CODE_ / CURRENCY_ENUM_. The same rule applies to every enum-typed field on this page (paymentProvider: "PROVIDER_TYPE_TAP", view: "VIEW_BASIC", …). See gRPC transcoding for why.

curl -X POST https://gateway.invora.app/api/billing/v2/customers \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "externalId": "cust_001",
    "name": "Acme Trading Co.",
    "email": "billing@acme.example",
    "currency": "CURRENCY_ENUM_SAR",
    "country": "COUNTRY_CODE_SA"
  }'
Response
{
  "customer": {
    "id": "cust_001",
    "externalId": "cust_001",
    "name": "Acme Trading Co.",
    "currency": "CURRENCY_ENUM_SAR"
  }
}
Field Required Description
externalId ✓ Your identifier for this customer. Unique within your tenant; you choose it, it is what PaymentRequestService.Create matches on, and it is returned as the customer's id.
name Display name on invoices and the Tap checkout page. Only externalId carries field_behavior = REQUIRED; a Create omitting name was accepted with 200 and an empty name. Send one anyway — it is what your customer sees.
email Where payment-request and invoice emails go.
currency Full enum name (CURRENCY_ENUM_SAR) — not "SAR". Defaults to your tenant's default currency.
country Full enum name (COUNTRY_CODE_SA) — not the bare alpha-2 "SA". See the enum warning above.

CreateRequest also accepts the full billing profile — legalName, legalNumber, taxIdentificationNumber, addressLine1/addressLine2, city, zipcode, state, phone, timezone, netPaymentTerm, invoiceGracePeriod, metadata, and the paymentProvider/paymentProviderCode/providerCustomer trio from step 3 — see the API reference for the per-field schema. Setting the provider fields here does the work of step 3 at creation time.

List customers

List is currently decommissioned — expect 500, not 200

The customer→party decouple (LAGO-2 #11) retired this RPC on the deployed backend. Once preconditions 1–3 hold, the call answers:

HTTP 500
{"code":2,"message":"NotImplementedError: CustomersQuery is served by Invora PartiesService.List post-decouple (LAGO-2 #11); see customer-list API slice in docs/lago-document-pipeline-migration.md.","details":[]}

A 401, 403, 400 or 503 here is one of the precondition failures in the readiness table, not the decommission — credentials do still change this call's answer. Verified live on the same endpoint: an unauthenticated call answers 403, and the 500 above appears only once a permitted, tenant-resolved principal reaches it.

This is a different situation from the readiness-check 500 row's "harmless, proceed to step 1" case — here it is the failure of the only path this section describes. To read an existing customer back, call GET /api/billing/v2/customers/{externalId} — the {id} segment takes the externalId you supplied (see above); that RPC is not part of the decommission.

List is a POST (it carries a filter body), and it is also the readiness check from the Prerequisites — see the 500 row there before relying on this call.

curl -X POST https://gateway.invora.app/api/billing/v2/customers/list \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "pagination": { "limit": 20 } }'
Response — the contract shape; not what List currently returns, see the warning above
{
  "items": [
    {
      "id": "cust_001",
      "externalId": "cust_001",
      "name": "Acme Trading Co.",
      "currency": "CURRENCY_ENUM_SAR"
    }
  ],
  "totalCount": 1
}

The list envelope is items, not customers

ListResponse follows the platform-wide list shape — items / totalCount / nextPageCursor (see List & filtering). Only the single-object responses (Create, Get, Update) wrap the record in customer.

When List is available, items[].id is the BILLING_CUSTOMER_ID every later example uses. ListRequest also accepts filter, sort, readMask and view. On the deployed backend today, read the customer directly instead: GET /api/billing/v2/customers/{externalId}, which is unaffected by the decommission.

There is no collection-level lookup to fall back on: GET /api/billing/v2/customers answers 405 with an empty body — the collection path accepts POST only, for Create — so a query string such as ?externalId=… has nothing to attach to. The single-customer read above is the whole find-path.

Before you can generate a payment link, you must link the billing customer to the Tap provider. This creates the provider-customer record (PaymentProviderCustomers::TapCustomer) that GetPaymentUrl checks at runtime. Do this once per customer, any time after step 1.

Use the customer's id from step 2 — the externalId you supplied — and the same code you chose when connecting Tap.

curl -X PUT https://gateway.invora.app/api/billing/v2/customers/BILLING_CUSTOMER_ID \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "paymentProvider": "PROVIDER_TYPE_TAP",
    "paymentProviderCode": "tap_main",
    "providerCustomer": { "syncWithProvider": true },
    "updateMask": "paymentProvider,paymentProviderCode,providerCustomer"
  }'
Response
{
  "customer": {
    "id": "BILLING_CUSTOMER_ID",
    "paymentProvider": "PROVIDER_TYPE_TAP",
    "paymentProviderCode": "tap_main"
  }
}
Field Required Description
paymentProvider ✓ Provider type enum. Use "PROVIDER_TYPE_TAP" for Tap Payments (the full enum name, not the slug tap).
paymentProviderCode ✓ The code from step 1 (e.g. tap_main).
providerCustomer.syncWithProvider ✓ Set true to create the provider-customer record in the billing backend. Without this the GetPaymentUrl call returns 400 no_linked_payment_provider.
updateMask ✓ Comma-separated list of camelCase field names to update (gRPC-JSON FieldMask convention). Must include all three fields above.

400 no_linked_payment_provider

If you call GetPaymentUrl before completing this step, the API returns:

{ "code": 3, "message": "Validation errors: {\"base\":[\"no_linked_payment_provider\"]}" }
Return to this step and link the customer before retrying.

Given a finalized invoice, get a Tap-hosted checkout URL and redirect the customer to it.

curl -X POST https://gateway.invora.app/api/billing/v2/payments/get-payment-url \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "invoiceId": "01963e21-0e46-7000-8d3a-c7f9b2e15a4c" }'
Response
{
  "paymentUrl": "https://secure.tap.company/v2/..."
}

Redirect the customer's browser to paymentUrl. After they complete or abandon the payment, Tap returns them to the successRedirectUrl from step 1. The final result arrives asynchronously via webhooks — do not rely on the redirect alone to confirm payment.

The invoice's billing customer must be linked to a Tap provider (step 3) and the invoice must be payable (non-zero, awaiting payment). If you skipped step 3, you will see the error below — return to step 3 first.

Common errors:

400 — no provider linked to this customer (step 3 missing)
{
  "code": 3,
  "message": "Validation errors: {\"base\":[\"no_linked_payment_provider\"]}",
  "details": []
}
404 — invoice not found
{
  "code": 5,
  "message": "Couldn't find Invoice ...",
  "details": []
}

5. Send a Payment Request

A payment request bundles one or more outstanding invoices into a single payable unit, optionally emails the customer, and — when a saved card is available — can charge it automatically.

curl -X POST https://gateway.invora.app/api/billing/v2/payments/requests \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "externalCustomerId": "cust_001",
    "billingProviderInvoiceIds": ["01963e21-0e46-7000-8d3a-c7f9b2e15a4c", "01963f20-1a4c-7000-9e2b-d8a0c3f26b5d"],
    "email": "customer@example.com"
  }'
Response
{
  "paymentRequest": {
    "id": "01963f30-2b5d-7000-ae3c-e9b1d4073c6e",
    "amountCents": "15000",
    "amountCurrency": "CURRENCY_ENUM_SAR",
    "email": "customer@example.com",
    "paymentStatus": "INVOICE_PAYMENT_STATUS_TYPE_PENDING",
    "createdAt": "2026-06-28T10:00:00Z"
  }
}
Field Required Description
externalCustomerId ✓ Your customer's external ID in Invora Billing.
billingProviderInvoiceIds ✓ Invoice IDs to include in the request.
email Address to send the payment-request email.
paymentMethod A saved payment method to auto-charge without redirecting the customer — see Saved cards.

Amounts are stringified integers

amountCents is a 64-bit integer serialized as a JSON string (gRPC-JSON convention), and amountCurrency uses the full enum name (CURRENCY_ENUM_SAR). 15000 means 150.00 SAR.

Use the returned id to manage the request:

# List payment requests
curl -X POST https://gateway.invora.app/api/billing/v2/payments/requests/list \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'

# Download a PDF receipt after payment
curl -X GET https://gateway.invora.app/api/billing/v2/payments/requests/01963f30-2b5d-7000-ae3c-e9b1d4073c6e/receipt \
  -H "Authorization: Bearer $TOKEN" -o receipt.pdf

# Resend the request email
curl -X POST https://gateway.invora.app/api/billing/v2/payments/requests/01963f30-2b5d-7000-ae3c-e9b1d4073c6e/resend-email \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'

If externalCustomerId does not exist, the API returns 404 with "message": "customer_not_found".

Saved Cards and Recurring Payments

With saveCardEnabled: true, the customer's first checkout stores their card against their customer record. For subsequent invoices, Invora charges that card automatically — no redirect, no customer interaction. This is a merchant-initiated transaction.

To use it, pass the saved paymentMethod reference in a payment request instead of redirecting the customer.

Requirements:

  1. saveCardEnabled: true on the Tap provider (set at create or update time).
  2. Your Tap account is approved for merchant-initiated / recurring payments (Tap KYC).

Saved-card activation requires Tap KYC

Setting saveCardEnabled: true succeeds even if your Tap account is not yet approved for recurring charges. The failure surfaces later, on the first attempt to reuse a stored card. Test the full saved-card flow in Tap's sandbox before going live.

Webhook Events

Subscribe to billing events to react to payment outcomes in real time. See Webhooks for endpoint registration, signature verification, and the retry policy.

Two distinct webhook channels

Tap delivers raw charge results to Invora's internal receiver — you never configure or see that. What you receive are Invora billing events, delivered from Invora to the webhook endpoint you register. Those are the events below.

Subscribe to these event types (use the name without the EVENT_TYPE_ prefix):

Event Delivered webhook_type Fires when
PAYMENT_SUCCEEDED payment.succeeded A payment is captured.
INVOICE_PAYMENT_STATUS_UPDATED invoice.payment_status_updated An invoice's payment status becomes succeeded or failed.
INVOICE_PAYMENT_FAILURE invoice.payment_failure An invoice payment attempt fails.
PAYMENT_REQUIRES_ACTION payment.requires_action 3D Secure or other customer action is required.
PAYMENT_REQUEST_PAYMENT_STATUS_UPDATED payment_request.payment_status_updated A payment request's status changes.
PAYMENT_REQUEST_PAYMENT_FAILURE payment_request.payment_failure A payment-request attempt fails.
PAYMENT_RECEIPT_CREATED payment_receipt.created A receipt is generated after collection.
CUSTOMER_PAYMENT_PROVIDER_CREATED customer.payment_provider_created A customer is linked to a payment provider.

A delivered event looks like this:

payment.succeeded
{
  "webhook_type": "payment.succeeded",
  "object_type": "payment",
  "organization_id": "317842111002338820",
  "payment": {
    "invora_id": "01963f40-3c6e-7000-be4d-fac2e518fd7f",
    "external_customer_id": "cust_001",
    "invoice_ids": ["01963e21-0e46-7000-8d3a-c7f9b2e15a4c"],
    "amount_cents": 15000,
    "amount_currency": "SAR",
    "status": "CAPTURED",
    "payment_status": "succeeded",
    "type": "tap",
    "provider_payment_id": "chg_xxxxxxxxxxxxxxxxxxxxxxxx",
    "payment_provider_code": "tap_main",
    "created_at": "2026-06-28T10:05:00Z"
  }
}

Webhook payloads use snake_case

Event payloads delivered to your endpoint use snake_case keys. This differs from the REST API responses above, which use camelCase.

Payment status mapping

Invora maps each Tap charge status to a billing payment_status:

Tap status Invora payment_status Meaning
CAPTURED succeeded Payment captured; funds will settle.
INITIATED (in progress) Awaiting customer action on the checkout page.
DECLINED failed Card declined by the issuer.
RESTRICTED failed Blocked by Tap risk rules.
FAILED failed Generic failure.
TIMEDOUT failed Customer did not complete checkout in time.
ABANDONED failed Customer left the checkout page.
CANCELLED failed Charge cancelled.
EXPIRED failed Checkout link expired.
UNKNOWN failed Unrecognized status — treat as failed.

Error Reference

Most error responses include a code, a message, and a details array. See Error handling for the full status-code reference and retry strategy — and note the two exceptions called out below, both of which break that shape.

HTTP gRPC code Cause
400 INVALID_ARGUMENT (3) Missing required field, or no payment provider linked to the customer (no_linked_payment_provider).
400 FAILED_PRECONDITION (9) Billing is not provisioned for this tenant — No billing configuration exists for tenant '<tenant-id>'. A tenant-state problem; see below.
403 PERMISSION_DENIED (7) The calling principal does not hold Invora.Billing — or the Authorization header was omitted entirely. A credential problem, not the unprovisioned-tenant answer. Arabic-only message; see below.
404 NOT_FOUND (5) Invoice or customer does not exist (customer_not_found). For a customer read the message names the key you sent — Party '<key>' not found in Invora.
429 RESOURCE_EXHAUSTED (8) Rate limit exceeded. Back off and retry.
503 (no body) Invora could not resolve your tenant. Not a Tap outage. See below.

Three ways a billing call fails before it does any work

"Billing isn't set up" is not one condition and does not produce one error. Three independent checks run, in this order, and each answers with a different status. They are easy to confuse because all three fire before your request is looked at — but the fix for each is completely different, so read the status, not the vibe.

(These are preconditions 1–3 from the Prerequisites. Precondition 4 — a billing customer linked to a payment provider — is different in kind: it is checked while the call runs, and fails with 400 INVALID_ARGUMENT / no_linked_payment_provider.)

# The check What it is about Failure
1 Tenant resolution — your token's organization maps to a tenant Your token 503 with no body
2 Authorization — the caller holds the Invora.Billing permission Your credential's grants 403 PERMISSION_DENIED (7)
3 Billing provisioning — a billing configuration exists for the tenant Your tenant 400 FAILED_PRECONDITION (9)

The 403 is about permissions, not about the tenant

PERMISSION_DENIED on a billing endpoint means the calling principal lacks Invora.Billing. It does not mean "this tenant has no billing configuration", and granting the permission does not make an unprovisioned tenant work — it moves you from check 2 to check 3, and the same call then answers 400 FAILED_PRECONDITION.

So a client that treats 403 as "billing not provisioned" and only handles 403 will fall straight through the moment the permission is granted. Handle 400/9 and 403/7 separately.

Because check 1 runs first, a token that resolves no tenant gets the body-less 503 on every endpoint on this page — the permission and provisioning answers below are only reachable once tenant resolution succeeds.

403 — the caller does not hold Invora.Billing (check 2)
{
  "code": 7,
  "message": "المستخدم غير مصرح له",
  "details": []
}
400 — the tenant was never provisioned for billing (check 3)
{
  "code": 9,
  "message": "No billing configuration exists for tenant '8c7b6f50-c5d0-4ccc-be91-8873d55dbfe2'.",
  "details": []
}

All three checks apply to every endpoint on this page — integrations/tap, customers, customers/list, the checkout/portal URLs, payments/get-payment-url and payments/requests alike. In particular there is no endpoint you can reach without Invora.Billing, so a 403 from any of them means the same thing everywhere: fix the credential's grants.

Branch on code, never on message

The 403 message is Arabic-only in every environment, and does not change with Accept-Language or x-invora-culture — the English equivalent ("User is not authorized") exists server-side but is not the one serialized. It also carries no details, so there is nothing machine-readable in the body beyond code: 7.

The 400 body does carry the tenant id in its message. That is the value support@invora.app needs — the message text is the only place it appears, so capture the whole string rather than just the status.

503 with an empty body is not a Tap outage

The table above used to attribute every 503 to Tap. In practice the 503 you are most likely to meet comes from Invora, before any Tap call is attempted, when the platform cannot resolve your token's organization to a tenant. It is distinguishable at a glance because it carries no body at all:

HTTP/1.1 503 Service Unavailable
Content-Length: 0
server: Kestrel
retry-after: 1
x-correlation-id: 6be3a8a7933b4fa5a505f2160e37ab34

There is no code, no message, no details — so any client that parses the body before inspecting the status will throw on the parse rather than report a 503. Handle it explicitly:

  • Retry with backoff. retry-after is advisory and typically far too optimistic; treat it as a floor, not a schedule.
  • Keep x-correlation-id. It is the only handle on the request, and it is what support@invora.app needs to trace it.
  • Do not interpret it as a payment failure. No charge is created, so it is always safe to retry an idempotent call.

End-to-End Example

Collect payment on a single finalized invoice, start to finish.

# Readiness check (before step 1). A 500 NotImplementedError here is EXPECTED — List is
#    decommissioned (see the Prerequisites table's 500 row) — proceed to step 1 regardless.
#    Stop only on 401 / 403 / 400 / 503; those mean a precondition is actually missing.
curl -s -o /dev/null -w 'readiness: HTTP %{http_code}\n' \
  -X POST https://gateway.invora.app/api/billing/v2/customers/list \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'

# 1. Connect Tap (once per tenant)
curl -X POST https://gateway.invora.app/api/billing/v2/integrations/tap \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "apiKey": "sk_test_YOUR_TAP_KEY",
    "code": "tap_main",
    "name": "Tap Payments",
    "successRedirectUrl": "https://your-app.com/payment/success",
    "supports3ds": true
  }'
# -> { "tapProvider": { "id": "7705b5eb-...", "code": "tap_main", ... } }

# 2. Create the billing customer (or read an existing one back — see below).
#    BILLING_CUSTOMER_ID below is the customer's `id`, which is the externalId you supplied.
curl -X POST https://gateway.invora.app/api/billing/v2/customers \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "externalId": "cust_001",
    "name": "Acme Trading Co.",
    "email": "billing@acme.example",
    "currency": "CURRENCY_ENUM_SAR"
  }'
# -> { "customer": { "id": "cust_001", "externalId": "cust_001", ... } }
#
#    Already have one? `List` is currently decommissioned (500 NotImplementedError — see the
#    Prerequisites readiness-check table). Read it back directly instead:
#      curl -H "Authorization: Bearer $TOKEN" \
#        https://gateway.invora.app/api/billing/v2/customers/cust_001

# 3. Link the billing customer to Tap (once per customer)
curl -X PUT https://gateway.invora.app/api/billing/v2/customers/BILLING_CUSTOMER_ID \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "paymentProvider": "PROVIDER_TYPE_TAP",
    "paymentProviderCode": "tap_main",
    "providerCustomer": { "syncWithProvider": true },
    "updateMask": "paymentProvider,paymentProviderCode,providerCustomer"
  }'
# -> { "customer": { "id": "BILLING_CUSTOMER_ID", "paymentProvider": "PROVIDER_TYPE_TAP", ... } }

# 4. Get a payment link for invoice 01963e21-0e46-7000-8d3a-c7f9b2e15a4c
curl -X POST https://gateway.invora.app/api/billing/v2/payments/get-payment-url \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{ "invoiceId": "01963e21-0e46-7000-8d3a-c7f9b2e15a4c" }'
# -> { "paymentUrl": "https://secure.tap.company/v2/..." }

# 5. Redirect the customer to paymentUrl. Tap handles card entry + 3DS,
#    then returns them to successRedirectUrl.

# 6. Your webhook endpoint receives payment.succeeded, then
#    invoice.payment_status_updated (payment_status: "succeeded").

# 7. Download the receipt
curl -X GET https://gateway.invora.app/api/billing/v2/payments/requests/<request_id>/receipt \
  -H "Authorization: Bearer $TOKEN" -o receipt.pdf