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

  1. Connect Tap once — register your Tap secret key as a payment provider on your tenant.
  2. 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.
  3. Generate a payment link for a finalized invoice, or send a payment request that bundles one or more invoices.
  4. The customer pays on the Tap-hosted checkout page (card entry + 3D Secure handled by Tap).
  5. Invora reconciles the result and emits billing webhook events (payment.succeeded, invoice.payment_status_updated, …) to your registered endpoint.
flowchart LR
    A[Connect Tap<br/>once per tenant] --> 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

  • Billing is provisioned for your tenant. This happens automatically at onboarding. If billing calls return FAILED_PRECONDITION, contact support@invora.app.
  • You have a billing token. A tenant owner automatically holds the Invora.Billing role — no separate access request is needed. Obtain a token with the client-credentials or password grant.
  • You have a Tap secret key from the Tap dashboard:
    • Sandbox (test): sk_test_…
    • Production (live): sk_live_…

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.

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 internal billing id (returned by CustomersService.Create or List) 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 2) and the invoice must be payable (non-zero, awaiting payment). If you skipped step 2, you will see the error below — return to step 2 first.

Common errors:

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

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

Every error response includes a code, a message, and a details array. See Error handling for the full status-code reference and retry strategy.

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. See Prerequisites.
404 NOT_FOUND (5) Invoice or customer does not exist (customer_not_found).
429 RESOURCE_EXHAUSTED (8) Rate limit exceeded. Back off and retry.
503 UNAVAILABLE (14) Tap is temporarily unreachable. Retry with exponential backoff.

End-to-End Example

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

# 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. Link the billing customer to Tap (once per customer)
#    Use the customer's internal billing id from CustomersService.Create / List.
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", ... } }

# 3. 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/..." }

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

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

# 6. 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