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:
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.
- Connect Tap once — register your Tap secret key as a payment provider on your tenant. See Connect Tap.
- Have a billing customer — create one, or read an existing one back by the
externalIdyou gave it (Listis currently decommissioned — see the note in Create or find the billing customer). Every later step is keyed on the customer'sid, which on the deployed backend is that sameexternalId. - Link each billing customer to Tap — call
CustomersService.UpdatewithpaymentProvider: "PROVIDER_TYPE_TAP"andsyncWithProvider: true. This creates the provider-customer record thatGetPaymentUrlrequires. This step is mandatory — skipping it returns400 no_linked_payment_provider. See Link the customer to Tap. - 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.
- The customer pays on the Tap-hosted checkout page (card entry + 3D Secure handled by Tap). Not a separate API call.
- 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.
- Your token resolves a tenant. The token must carry the Invora project audience and
urn:zitadel:iam:user:resourceownerso the platform can resolve your organization to a tenant — see Authentication. Without a resolvable tenant you get an empty-bodied503, not a billing error. - The calling principal holds the
Invora.Billingpermission. 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 withPERMISSION_DENIEDuntilInvora.Billingis added to its grant. - 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_PRECONDITIONand the messageNo 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. - A billing customer exists and is linked to a payment provider. Steps
2 and 3 below do
this. Until step 3 is done,
GetPaymentUrland the checkout/portal URLs fail withno_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
}'
{
"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"
}'
{
"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 } }'
{
"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.
3. Link the Customer to Tap¶
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"
}'
{
"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:
4. Generate a Payment Link for an Invoice¶
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" }'
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:
{
"code": 3,
"message": "Validation errors: {\"base\":[\"no_linked_payment_provider\"]}",
"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"
}'
{
"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:
saveCardEnabled: trueon the Tap provider (set at create or update time).- 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:
{
"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.
{
"code": 7,
"message": "المستخدم غير مصرح له",
"details": []
}
{
"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-afteris 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
Related¶
- Authentication — obtaining access tokens.
- Billing & subscriptions — plans, subscriptions, customers, and payment providers.
- Webhooks — endpoint registration, signatures, and retry policy.
- Error handling — status codes and retry strategy.
- Marketplace & intermediary invoicing — Tap payment collection in multi-tenant platform scenarios.