Over the Air API
The Over the Air (OTA) API lets you initiate and manage in-person card payments on Stitch terminals from your backend — no interaction with the terminal itself is required. The core concept is the terminal session: a server-initiated request that tells a specific terminal to collect a card payment for a given amount. Your backend creates the session via the API, Stitch delivers it to the terminal over the air, and the terminal guides the customer through the card acceptance flow. Once the payment completes (or fails), you receive the result via webhook.
The payment flow works as follows:
- Your backend creates a terminal session — send a
POST /terminal-sessionsrequest with the charge amount and the target terminal identifier. - Stitch delivers the session to the terminal — the device wakes up and displays the payment amount. No manual action is needed on the terminal.
- The customer presents their card — the terminal handles the EMV card acceptance flow (tap, insert, or swipe).
- Stitch processes the payment — the transaction is routed to the issuer through the card network for authorisation.
- The terminal displays the result — the customer sees whether the payment was approved or declined.
- Your backend receives a webhook — a
terminal-session.successorterminal-session.failureevent is sent to your registered endpoint. - (Optional) Poll for the session — if you need to confirm the result or missed the webhook, fetch the session via
GET /terminal-sessions/{id}.
Authentication
The OTA API uses OAuth2 client credentials. Before making any API calls, your server must request an access token from the Stitch authorization server. This token authenticates all subsequent requests and is scoped to specific permissions.
Request an access token with the client_terminal_session scope:
curl -X POST https://secure.stitch.money/connect/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "scope=client_terminal_session"
The response includes an access_token and expires_in value (in seconds). Store the token server-side and reuse it until it expires — there's no need to request a new token for every API call.
All subsequent API calls use Authorization: Bearer <access_token>. See the client token docs for full details.
Creating a Terminal Session
POST https://api.stitch.money/v2/terminal-sessions
To initiate a payment, send a request with the charge amount and the target terminal. Stitch delivers the session to the terminal over the air — the device wakes up, displays the payment amount, and prompts the customer to present their card. No action is needed on the terminal itself.
The API responds immediately with a pending session. The actual payment result arrives asynchronously via webhook, or you can poll for it.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
intent.charge.amount | number | Yes | Charge amount in major units (e.g. 350.5 for R350.50). |
intent.charge.currency | string | Yes | ISO 4217 currency code. Only ZAR is currently supported. |
intent.charge.cashback.amount | number | No | Cashback amount in major units, dispensed to customer on top of the charge. |
intent.charge.cashback.currency | string | No | Cashback currency. Only ZAR. |
terminal | string | Yes | The terminal identifier where the payment should be processed. |
nonce | string | Yes | A unique identifier for idempotency. Reusing a nonce returns an error. |
externalReference | string | No | Your own reference for this session. Not enforced as unique. |
metadata | object | No | Arbitrary key-value pairs to attach to the session. |
customer.name | string | No | Customer name. |
customer.email | string | No | Customer email. |
customer.phone | string | No | Customer phone including country code (e.g. +27612345678). |
customer.identifyingDocument | object | No | Government-issued identity or travel document. Required sub-fields below. |
customer.identifyingDocument.type | string | Yes | One of identity_document, passport, or temporary_residence. |
customer.identifyingDocument.country | string | Yes | ISO 3166-1 alpha-2 country code (e.g. ZA). |
customer.identifyingDocument.number | string | Yes | The document number. |
customer.externalReference | string | No | Your identifier for this customer. |
The nonce field prevents duplicate payments. Each terminal session must have a unique nonce — reusing one returns an error. Generate a unique value (e.g. a UUID) for each payment attempt.
Examples
- Basic Charge
- Charge with Cashback
curl -X POST https://api.stitch.money/v2/terminal-sessions \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{
"intent": {
"charge": {
"amount": 350.5,
"currency": "ZAR"
}
},
"terminal": "dGVybWluYWwvMmNiN2NhMTQtNGQwYi00ODg3LTk2MWItOTRmYWYzMzJkMGYw",
"nonce": "9599a853-4333-4359-89de-658bfc86773a"
}'
curl -X POST https://api.stitch.money/v2/terminal-sessions \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{
"intent": {
"charge": {
"amount": 350.5,
"currency": "ZAR",
"cashback": {
"amount": 50,
"currency": "ZAR"
}
}
},
"terminal": "dGVybWluYWwvMmNiN2NhMTQtNGQwYi00ODg3LTk2MWItOTRmYWYzMzJkMGYw",
"nonce": "9599a853-4333-4359-89de-658bfc86773a"
}'
The total amount charged to the customer will be R400.50 (R350.50 charge + R50.00 cashback).
Response
The API returns immediately with the session in pending status. Store the session id — you'll need it to match incoming webhooks or to poll for the result.
{
"id": "ts_i0sspe2eZvKYDS2Cd31jOCgZ",
"terminal": "dGVybWluYWwvMmNiN2NhMTQtNGQwYi00ODg3LTk2MWItOTRmYWYzMzJkMGYw",
"intent": {
"charge": {
"amount": 350.5,
"currency": "ZAR"
}
},
"status": "pending",
"nonce": "9599a853-4333-4359-89de-658bfc86773a",
"metadata": {},
"createdAt": "2024-01-15T09:30:00Z",
"updatedAt": "2024-01-15T09:30:00Z"
}
Terminal Session Statuses
| Status | Description |
|---|---|
pending | Session is awaiting completion on the terminal. |
success | Payment was processed successfully. The outcome field contains the charge details. |
failure | Session did not complete. The failureReason field explains why. |
Failure Reasons
| Reason | Description |
|---|---|
expired | Session timed out before the terminal could complete. |
cancelled_by_terminal | Operator cancelled the payment on the device. |
offline_terminal | Terminal was not connected when the session was created. |
busy_terminal | Terminal was already processing another session. |
Handling Webhooks
Webhooks are the primary way your system learns the outcome of a terminal session. When the customer completes (or fails to complete) the card payment, Stitch sends an HTTP POST to your registered webhook endpoint.
There are two event types:
terminal-session.success— the payment was processed successfully.terminal-session.failure— the session failed for one of the reasons listed above.
Always verify the webhook signature before trusting the payload. Use the session id from the webhook to match it back to the payment you initiated. See the webhooks documentation for signature verification details and setup.
Success Webhook
On success, data.outcome.charge contains the full charge details — card information, retrieval reference number, and settlement status. Use the charge.id if you need to issue a refund later. The retrievalReferenceNumber is the bank reference you'll need for reconciliation.
{
"id": "wev_1o1jU6Y8whOrGcmXzfeDq3wQ",
"type": "terminal-session.success",
"data": {
"id": "ts_i0sspe2eZvKYDS2Cd31jOCgZ",
"terminal": "dGVybWluYWwvMmNiN2NhMTQtNGQwYi00ODg3LTk2MWItOTRmYWYzMzJkMGYw",
"intent": {
"charge": {
"amount": 350.5,
"currency": "ZAR"
}
},
"status": "success",
"outcome": {
"charge": {
"id": "charge_1Q0PsIJvEtkwdCNYMSaVuRz6",
"type": "in_person_card",
"card": {
"id": "Y2FyZC9mYTY5ZGQ3Yi03OTY1LTQ3MzUtOWZiNy1mZjEwZTY2OTAwMjY=",
"bin": "42424242",
"last4": "4242",
"expiry": {
"month": "12",
"year": "29"
},
"network": "visa",
"fundingType": "debit",
"issuer": {
"name": "standard bank",
"country": "ZA"
},
"metadata": {},
"createdAt": "2024-01-15T09:30:00Z",
"updatedAt": "2024-01-15T09:35:00Z"
},
"capture": {
"method": "automatic"
},
"retrievalReferenceNumber": "1234567890",
"networkTransactionIdentifier": "1234567890",
"amount": 350.5,
"currency": "ZAR",
"status": "success",
"metadata": {},
"createdAt": "2024-01-15T09:30:00Z",
"updatedAt": "2024-01-15T09:35:00Z"
}
},
"nonce": "9599a853-4333-4359-89de-658bfc86773a",
"metadata": {},
"createdAt": "2024-01-15T09:30:00Z",
"updatedAt": "2024-01-15T09:35:00Z"
},
"createdAt": "2024-01-15T09:36:00Z"
}
Failure Webhook
On failure, data.failureReason tells you what went wrong. There is no outcome field since no charge was created. Your system should update the order status accordingly — in most cases you can prompt the customer to retry or create a new session.
{
"id": "wev_3p9xA2mQ6LfHjR3kUb1tW7nP",
"type": "terminal-session.failure",
"data": {
"id": "ts_i0sspe2eZvKYDS2Cd31jOCgZ",
"terminal": "dGVybWluYWwvMmNiN2NhMTQtNGQwYi00ODg3LTk2MWItOTRmYWYzMzJkMGYw",
"intent": {
"charge": {
"amount": 350.5,
"currency": "ZAR"
}
},
"status": "failure",
"failureReason": "expired",
"nonce": "9599a853-4333-4359-89de-658bfc86773a",
"metadata": {},
"createdAt": "2024-01-15T09:30:00Z",
"updatedAt": "2024-01-15T09:35:00Z"
},
"createdAt": "2024-01-15T09:36:00Z"
}
Fetching a Terminal Session
GET https://api.stitch.money/v2/terminal-sessions/{id}
While webhooks are the recommended way to receive results, you can also poll this endpoint as a fallback — for example, if your webhook endpoint was temporarily unavailable or you need to confirm the current state of a session.
The response is a terminal session object in its current state — pending, success, or failure — with the same structure as the webhook data field.
curl https://api.stitch.money/v2/terminal-sessions/ts_i0sspe2eZvKYDS2Cd31jOCgZ \
-H "Authorization: Bearer <access_token>"
- Pending
- Success
- Failure
The session has been created but the terminal hasn't completed the payment yet.
{
"id": "ts_i0sspe2eZvKYDS2Cd31jOCgZ",
"terminal": "dGVybWluYWwvMmNiN2NhMTQtNGQwYi00ODg3LTk2MWItOTRmYWYzMzJkMGYw",
"intent": {
"charge": {
"amount": 350.5,
"currency": "ZAR"
}
},
"status": "pending",
"nonce": "9599a853-4333-4359-89de-658bfc86773a",
"metadata": {},
"createdAt": "2024-01-15T09:30:00Z",
"updatedAt": "2024-01-15T09:30:00Z"
}
The payment was processed successfully. The outcome.charge field contains the full charge details.
{
"id": "ts_i0sspe2eZvKYDS2Cd31jOCgZ",
"terminal": "dGVybWluYWwvMmNiN2NhMTQtNGQwYi00ODg3LTk2MWItOTRmYWYzMzJkMGYw",
"intent": {
"charge": {
"amount": 350.5,
"currency": "ZAR"
}
},
"status": "success",
"outcome": {
"charge": {
"id": "charge_1Q0PsIJvEtkwdCNYMSaVuRz6",
"type": "in_person_card",
"card": {
"id": "Y2FyZC9mYTY5ZGQ3Yi03OTY1LTQ3MzUtOWZiNy1mZjEwZTY2OTAwMjY=",
"bin": "42424242",
"last4": "4242",
"expiry": {
"month": "12",
"year": "29"
},
"network": "visa",
"fundingType": "debit",
"issuer": {
"name": "standard bank",
"country": "ZA"
},
"metadata": {},
"createdAt": "2024-01-15T09:30:00Z",
"updatedAt": "2024-01-15T09:35:00Z"
},
"capture": {
"method": "automatic"
},
"retrievalReferenceNumber": "1234567890",
"networkTransactionIdentifier": "1234567890",
"amount": 350.5,
"currency": "ZAR",
"status": "success",
"metadata": {},
"createdAt": "2024-01-15T09:30:00Z",
"updatedAt": "2024-01-15T09:35:00Z"
}
},
"nonce": "9599a853-4333-4359-89de-658bfc86773a",
"metadata": {},
"createdAt": "2024-01-15T09:30:00Z",
"updatedAt": "2024-01-15T09:35:00Z"
}
The session failed. The failureReason field explains why. There is no outcome field since no charge was created.
{
"id": "ts_i0sspe2eZvKYDS2Cd31jOCgZ",
"terminal": "dGVybWluYWwvMmNiN2NhMTQtNGQwYi00ODg3LTk2MWItOTRmYWYzMzJkMGYw",
"intent": {
"charge": {
"amount": 350.5,
"currency": "ZAR"
}
},
"status": "failure",
"failureReason": "expired",
"nonce": "9599a853-4333-4359-89de-658bfc86773a",
"metadata": {},
"createdAt": "2024-01-15T09:30:00Z",
"updatedAt": "2024-01-15T09:35:00Z"
}
Refunds
POST https://api.stitch.money/v2/refunds
To refund a completed payment, send a request with the charge ID (or terminal session ID) and the amount to return. You can issue a full refund for the original amount, or a partial refund for a lesser amount. Multiple partial refunds against the same charge are supported up to the original charge amount.
Refunds require the client_refund scope, which is separate from the terminal session scope. Request an access token with both scopes if you need to create sessions and issue refunds.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
charge | string | No | The charge ID to refund (from outcome.charge.id). Either charge or terminalSession must be provided. |
terminalSession | string | No | The terminal session ID to refund. Either charge or terminalSession must be provided. |
amount | number | Yes | Refund amount in major units (e.g. 30.0 for R30.00). Can be less than the original charge for a partial refund. |
currency | string | Yes | ISO 4217 currency code. Only ZAR. |
reason | string | Yes | One of fraud, requested_by_user, duplicate_charge. |
nonce | string | Yes | Unique identifier for idempotency. |
externalReference | string | No | Your own reference for this refund. |
metadata | object | No | Arbitrary key-value pairs. |
You can identify the charge to refund using either the charge ID (from outcome.charge.id in the success webhook) or the terminalSession ID. Using the charge ID is more precise if a session could theoretically produce multiple charges in future.
Example
curl -X POST https://api.stitch.money/v2/refunds \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{
"charge": "charge_1Q0PsIJvEtkwdCNYMSaVuRz6",
"amount": 30.0,
"currency": "ZAR",
"reason": "requested_by_user",
"nonce": "a1b2c3d4-5678-90ab-cdef-1234567890ab"
}'
Response
{
"id": "refund_1234567890",
"type": "card",
"charge": "charge_1Q0PsIJvEtkwdCNYMSaVuRz6",
"amount": 30.0,
"currency": "ZAR",
"reason": "requested_by_user",
"status": "success",
"nonce": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
"createdAt": "2024-01-15T09:30:00Z",
"updatedAt": "2024-01-15T09:35:00Z"
}
Refund Statuses
| Status | Description |
|---|---|
success | Refund completed successfully. |
failure | Refund failed. See the failure object for details. |
You can check the status of a refund at any time:
curl https://api.stitch.money/v2/refunds/refund_1234567890 \
-H "Authorization: Bearer <access_token>"
Disbursements (Standalone Refunds)
POST https://api.stitch.money/v2/terminal-sessions
Disbursements (Standalone Refunds) let you push funds to a customer's card without referencing a prior charge. Unlike charge-linked refunds, which reverse a specific payment, a disbursement uses a terminal session to collect the customer's card details directly from their physical card. The terminal prompts the customer to present their card, reads the card details, and Stitch pushes the refund funds to that card.
This is useful for scenarios like goodwill refunds, compensation, or returns where the original transaction isn't available in Stitch — for example, if the charge was processed on a different system.
Disbursements use the same POST /v2/terminal-sessions endpoint as charges, but with an intent.disbursement payload instead of intent.charge.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
intent.disbursement.amount | number | Yes | Disbursement amount in major units (e.g. 150.0 for R150.00). |
intent.disbursement.currency | string | Yes | ISO 4217 currency code. Only ZAR is currently supported. |
terminal | string | Yes | The terminal identifier where the disbursement should be processed. |
nonce | string | Yes | A unique identifier for idempotency. Reusing a nonce returns an error. |
externalReference | string | No | Your own reference for this session. Not enforced as unique. |
metadata | object | No | Arbitrary key-value pairs to attach to the session. |
Example
curl -X POST https://api.stitch.money/v2/terminal-sessions \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{
"intent": {
"disbursement": {
"amount": 150.0,
"currency": "ZAR"
}
},
"terminal": "dGVybWluYWwvMmNiN2NhMTQtNGQwYi00ODg3LTk2MWItOTRmYWYzMzJkMGYw",
"nonce": "b7e3f1a2-9c84-4d5e-a6b0-8f2d4e6c0a1b"
}'
Response
The API returns immediately with the session in pending status, just like a charge session. The actual result arrives asynchronously via webhook or by polling.
{
"id": "ts_k3mRvN8pLqWxYZ5Af72jTBcE",
"terminal": "dGVybWluYWwvMmNiN2NhMTQtNGQwYi00ODg3LTk2MWItOTRmYWYzMzJkMGYw",
"intent": {
"disbursement": {
"amount": 150.0,
"currency": "ZAR"
}
},
"status": "pending",
"nonce": "b7e3f1a2-9c84-4d5e-a6b0-8f2d4e6c0a1b",
"metadata": {},
"createdAt": "2024-01-15T10:00:00Z",
"updatedAt": "2024-01-15T10:00:00Z"
}
Successful Session
On success, the session status changes to success and the outcome.disbursement field contains the full disbursement details — card information, retrieval reference number, and the amount pushed to the card.
{
"id": "ts_k3mRvN8pLqWxYZ5Af72jTBcE",
"terminal": "dGVybWluYWwvMmNiN2NhMTQtNGQwYi00ODg3LTk2MWItOTRmYWYzMzJkMGYw",
"intent": {
"disbursement": {
"amount": 150.0,
"currency": "ZAR"
}
},
"status": "success",
"outcome": {
"disbursement": {
"id": "disb_2R1QtJKwFulxeD4Bg83kUCdF",
"type": "in_person_card",
"card": {
"id": "Y2FyZC9mYTY5ZGQ3Yi03OTY1LTQ3MzUtOWZiNy1mZjEwZTY2OTAwMjY=",
"bin": "42424242",
"last4": "4242",
"expiry": {
"month": "12",
"year": "29"
},
"network": "visa",
"fundingType": "debit",
"issuer": {
"name": "standard bank",
"country": "ZA"
},
"metadata": {},
"createdAt": "2024-01-15T10:00:00Z",
"updatedAt": "2024-01-15T10:05:00Z"
},
"retrievalReferenceNumber": "9876543210",
"amount": 150.0,
"currency": "ZAR",
"status": "success",
"metadata": {},
"createdAt": "2024-01-15T10:00:00Z",
"updatedAt": "2024-01-15T10:05:00Z"
}
},
"nonce": "b7e3f1a2-9c84-4d5e-a6b0-8f2d4e6c0a1b",
"metadata": {},
"createdAt": "2024-01-15T10:00:00Z",
"updatedAt": "2024-01-15T10:05:00Z"
}
Webhooks
Disbursement sessions use the same terminal-session.success and terminal-session.failure webhook events as charge sessions. The only difference is that the outcome field contains a disbursement object instead of a charge object.
See Handling Webhooks for details on webhook setup and signature verification.