# Integration guide (/docs/server-to-server/integration-guide)

## Scope [#scope]

The complete Server-to-Server flow reference. Every payment scenario, the purchase lifecycle, tokenisation, webhooks and the operations available after authorisation.

If you have not yet completed a test transaction, start with the *Integration quick start* — it walks the six requests end to end. This page assumes that works and goes deeper.

## Authentication and keys [#authentication-and-keys]

| Key | Used for |
|---|---|
| **Bearer token** | Every API operation: clients, purchases, charge, capture, release, refund, cancel, webhooks, reporting |
| **S2S token** | Submitting card data to a purchase's `direct_post_url` |

<Callout type="warning">
**The S2S token is not on your dashboard.** Only the bearer token is. The S2S
token is issued to qualifying merchants by Precium, because submitting clear PAN
data puts you in PCI DSS SAQ-D scope. If you do not have one, you are not
provisioned for the full server-to-server flow; speak to your account contact
before building against it.
</Callout>

Both are bearer tokens on the `Authorization` header. Creating a purchase additionally requires your `brand_id`. The key also selects the environment — test and production share one base URL, and test data is fully isolated from live data.

## Transaction classification [#transaction-classification]

Every flow below is one of three classes. The class determines whether 3D Secure is presented, whether a CVC is required, and where chargeback liability sits.

| Class | Cardholder | 3D Secure | CVC | Liability |
|---|---|---|---|---|
| **CIT** | Present | Required | Required | Shifts to issuer on successful authentication |
| **CIT → MIT** | Present for the first payment | Required on the first payment | Required on the first payment | Shifts on the authenticated first payment |
| **MIT** | Not present | Not presented | Not used — may be stubbed | Rests on the original authentication being replayed correctly |

In South Africa the first charge against a card must always be authenticated. Subsequent merchant-initiated charges against the resulting token do not present a challenge.

## Purchase lifecycle [#purchase-lifecycle]

A purchase moves through a defined set of states. Read `status` to know where you are; never infer it from a browser redirect.

| Status | Meaning |
|---|---|
| `created` | Created, awaiting payment |
| `sent` / `viewed` | Invoice sent / checkout opened |
| `pending_charge` | Authorisation in flight |
| `hold` | Authorised, funds reserved, awaiting capture or release |
| `preauthorized` | Card verified with no financial transaction |
| `pending_capture` / `pending_release` / `pending_refund` | Operation accepted, acquirer still processing |
| `paid` | Captured and complete |
| `released` | Hold released without capture |
| `refunded` | Refunded in full |
| `cancelled` | Cancelled before payment |
| `chargeback` | Disputed by the cardholder |
| `expired` / `overdue` | Payment window elapsed / past its due date |
| `error` | Failed — inspect `transaction_data.attempts[]`, newest first |

## Flow 1 — Regular redirect (CIT) [#flow-1--regular-redirect-cit]

Create the purchase and send the customer to `checkout_url`. Precium hosts the card form, so you handle no card data.

```bash
POST https://gate.reviopay.com/api/v1/purchases/
{
  "client_id": "00000000-0000-4000-8000-000000000001",
  "brand_id":  "00000000-0000-4000-8000-000000000002",
  "purchase": { "currency": "ZAR", "products": [ { "name": "Order 1042", "price": 149900 } ] },
  "success_redirect": "https://www.example.com/success",
  "failure_redirect": "https://www.example.com/failure"
}
```

## Flow 2 — Direct Post (CIT) [#flow-2--direct-post-cit]

Your own form, posted straight to the purchase's `direct_post_url`. Card data never reaches your servers, so your PCI scope is raised only to SAQ A-EP.

The form takes six fields: `pm`, `cardholder_name`, `card_number`, `expires` (`MM/YY`), `cvc`, and `remember_card`. Validate them before submitting — a format error is returned as a payment failure, not a validation error.

```html
<form method='POST' action='{direct_post_url}'>
  <input type='hidden' name='pm' value='visa'>
  <input name='cardholder_name' maxlength='30' required>
  <input name='card_number'     maxlength='19' required>
  <input name='expires'         maxlength='5' placeholder='MM/YY' required>
  <input name='cvc'             maxlength='4' required>
  <input type='checkbox' name='remember_card' value='on'>
  <button type='submit'>Pay</button>
</form>
```

For server-side submission, post to `{direct_post_url}?s2s=true` as JSON using
your **S2S token**. That is the full S2S flow and requires SAQ-D.

<Callout type="warning">
A server-side submission needs **more than the six form fields**. In a browser
form the browser supplies its own context; posting server to server, you must
supply it yourself, and 3D Secure risk assessment depends on it:

`remote_ip`, `user_agent`, `accept_header`, `language`, `java_enabled`,
`javascript_enabled`, `color_depth`, `utc_offset`, `screen_width`,
`screen_height`.

See the [quick start](/docs/server-to-server/integration-quick-start) for a
complete request.
</Callout>

## The Direct Post response [#the-direct-post-response]

Whatever you post to `direct_post_url`, the HTTP status is **`200`**. The
outcome is in the `status` field of the body, and there are two shapes.

**The card is not enrolled in 3D Secure.** `status` is one of:

| `status` | Meaning |
|---|---|
| `executed` | Authorisation and capture completed |
| `authorized` | Funds reserved, because `skip_capture` was requested |
| `error` | The authorisation failed |

**The card is enrolled in 3D Secure.** `status` is `3DS_required` and the body
carries the fields needed to hand the customer to their bank.

<Callout type="warning">
Do not assume `3DS_required`. A card that is not enrolled returns a financial
status directly from this same call, so branch on `status` rather than
expecting an authentication step. South African cards are expected to be
enrolled, since 3DS is mandated locally; a non-enrolled card is more likely to
be foreign-issued.
</Callout>

### Completing a 3D Secure authentication [#completing-a-3d-secure-authentication]

| Field | What it is |
|---|---|
| `Method` | `GET` or `POST`. The method the browser must use. These are the only two values |
| `URL` | The issuer's Access Control Server. Not a Precium URL |
| `PaReq` | Pass through unchanged |
| `MD` | Pass through unchanged. **May arrive empty — send it on as an empty string** |

1. Send the customer's browser to `URL` using `Method`, carrying `PaReq` and
   `MD` as you received them, plus a `TermUrl` of your own. Use query
   parameters for `GET` and body parameters for `POST`.
2. The issuer presents its own authentication page. It is branded by the bank;
   you do not recreate it. It can take several seconds to appear, which is the
   issuer responding rather than a delay inside Precium.
3. On completion the issuer redirects the browser by **`POST` to your
   `TermUrl`**, with `MD` and `PaRes` in the request body.
4. Send `MD` and `PaRes` back to Precium **from your server** to complete the
   authorisation. This call takes **no authentication headers**.
5. Confirm the final outcome from the webhook, not from this response.

A frictionless authentication is treated exactly like a completed challenge:
3DS was invoked, the issuer judged the transaction low risk and did not show the
customer a challenge. Your integration does nothing differently.

Routing through a 3DS Brand invokes 3DS on every customer-initiated
transaction, but the issuer still decides whether the customer sees a challenge.

<Callout type="info">
A **recurring or token charge does not run a new 3DS challenge.** It uses the
credential established by the original transaction, and it does not carry a
fresh liability shift.
</Callout>

## Flow 3 — Preauthorisation (card verification) [#flow-3--preauthorisation-card-verification]

Verify a card and store it without moving money. Set `skip_capture: true`**and** a purchase total of zero.

```bash
POST https://gate.reviopay.com/api/v1/purchases/
{
  "client_id": "00000000-0000-4000-8000-000000000001",
  "brand_id":  "00000000-0000-4000-8000-000000000002",
  "purchase": { "currency": "ZAR", "products": [ { "name": "Card verification", "price": 0 } ] },
  "skip_capture": true,
  "force_recurring": true,
  "success_redirect": "https://www.example.com/success",
  "failure_redirect": "https://www.example.com/failure"
}
```

The result is `status: preauthorized`. Only cardholder verification happens — no funds are reserved and no financial transaction occurs. The card is stored, so the purchase id becomes a token for later merchant-initiated charges.

3D Secure is still required. A zero-amount authorisation is authenticated like any other customer-initiated payment.

<Callout>
**Not every issuer answers a zero-amount request.** Try zero first. If the
issuer times out or declines it, fall back to a **ZAR 1.00 authorisation** and
call `release` once the card has been stored. The card is saved either way.
</Callout>

## Amount limits [#amount-limits]

Amounts are sent **in cents**: ZAR 1.00 is `100`. This applies in test as well
as production.

| | |
|---|---|
| Minimum | No API minimum, but **South African banking rails will not settle a card amount below ZAR 1.00**. Use at least `100` for anything that must settle, including live testing |
| Maximum | No universal maximum. The practical ceiling comes from the merchant configuration, the issuer, cardholder limits and fraud controls. Agree high-value use cases during onboarding |

Reconciliation files are the exception: amounts there are in **major units**
with four decimal places, not cents. See [Reconciliation files](/docs/reconciliation).

## Purchase due time [#purchase-due-time]

A purchase carries a `due` time. **If you do not supply one, it defaults to one
hour.** Once that window elapses the purchase moves to `overdue`.

`overdue` is not a decline. It means the payment window passed, and by default
payment is still possible afterwards. Set `purchase.due_strict` to `true` to
refuse payment once `due` has passed.


## Flow 4 — Delayed capture (hold funds) [#flow-4--delayed-capture-hold-funds]

Reserve funds now and settle later — deposits, hotel bookings, car hire, anything where the final amount is not yet known. Set `skip_capture: true` with a **non-zero** total.

The purchase reaches `status: hold`. Resolve it one of two ways.

### Capture [#capture]

```bash
POST https://gate.reviopay.com/api/v1/purchases/{id}/capture/
{ "amount": 120000 }
```

`amount` is optional and in cents. Omit it to capture the full authorisation. Capture less and the remainder is released automatically — **you cannot capture the same authorisation twice**, so a partial capture closes it.

### Release [#release]

```bash
POST https://gate.reviopay.com/api/v1/purchases/{id}/release/
```

Releases the hold without taking payment.

### Both are asynchronous [#both-are-asynchronous]

If the acquirer is slow you get HTTP 200 with `status: pending_capture` or `pending_release`, plus a matching webhook. The outcome then arrives as `purchase.captured` (`status: paid`) or `purchase.released` (`status: released`).

On failure you get HTTP 400 with `purchase_capture_error` or `purchase_release_error`. For the reason, `GET /purchases/{id}/` and read `transaction_data.attempts[]` — newest first — where `.error` carries the code and description.

Holds expire. Card networks typically release an uncaptured authorisation within 7 to 30 days, so capture or release deliberately rather than letting it lapse.

## Flow 5 — Tokenisation and recurring charges (CIT → MIT) [#flow-5--tokenisation-and-recurring-charges-cit--mit]

Store a card on an authenticated first payment, then charge it later without the cardholder present.

### Store the card [#store-the-card]

Pass `remember_card=on` to the `direct_post_url`, or `force_recurring: true` on the purchase. On success **the purchase's own id becomes the card token**, and that purchase carries `is_recurring_token: true`.

### Charge the stored card [#charge-the-stored-card]

Create a new purchase for the new amount, then charge it against the original:

```bash
POST https://gate.reviopay.com/api/v1/purchases/{new_purchase_id}/charge/
{ "recurring_token": "{original_purchase_id}" }
```

HTTP 200 means the new purchase is paid. Use the same `recurring_token` for every subsequent charge — it does not rotate.

### List and delete tokens [#list-and-delete-tokens]

```bash
GET    /clients/{id}/recurring_tokens/
DELETE https://gate.reviopay.com/api/v1/clients/{id}/recurring_tokens/{token_id}/
POST   /purchases/{original_purchase_id}/delete_recurring_token/
```

Deleting via the purchase resets its `is_recurring_token` to false. Give customers a way to remove a stored card; you will need it.

## Flow 6 — Merchant-initiated charges (MIT) [#flow-6--merchant-initiated-charges-mit]

For a charge with no cardholder present, flag it as recurring and reference the prior transaction so the issuer can link the two.

```bash
"payment_method_details": {
  "card": {
    "is_recurring": true,
    "previous_network_transaction_id": "000000000000000",
    "original_amount_cents": 10000
  }
}
```

| Field | Notes |
|---|---|
| `is_recurring` | Boolean. Marks the transaction merchant-initiated |
| `previous_network_transaction_id` | From the original authenticated transaction. Ask your account manager if you do not hold it |
| `original_amount_cents` | Integer, in cents. The amount of the original deduction |

<Callout type="warning">
**CVC is not used for merchant-initiated transactions** and may be stubbed. Omitting the linking fields is permitted where you are approved for non-3DS processing, but it measurably affects issuer acceptance and moves chargeback exposure to you.
</Callout>

## payment_method_details [#payment_method_details]

Flows 7 and 8 both send a `payment_method_details` object. It nests **inside
`purchase`**, alongside `currency` and `products`, not at the top level of the
request.

<Callout type="warning">
`payment_method_details` is a **bespoke extension for the server-to-server
flow**. It is deliberately not part of the published OpenAPI schema and will not
appear in the [generated reference](/docs/reference/card) or in the Swagger
page. The fields below are the contract; treat this page as their source.
</Callout>

## Flow 7 — External MPI (your own 3D Secure) [#flow-7--external-mpi-your-own-3d-secure]

If you run your own Merchant Plug-In, authenticate first and pass the results on the purchase.

```json
"purchase": {
  "currency": "ZAR",
  "products": [ { "name": "My product or service", "price": 10000 } ],
  "payment_method_details": {
    "card": {
      "is_external_3DS": true,
      "authentication_transaction_id": "your-mpi-transaction-id",
      "cavv": "base64-cardholder-authentication-value",
      "eci_raw": "05",
      "xid": "3ds1-transaction-identifier",
      "is_recurring": true,
      "previous_network_transaction_id": "016153570198200"
    }
  }
}
```

| Field | Required | Notes |
|---|---|---|
| `is_external_3DS` | Yes | Must be `true` |
| `authentication_transaction_id` | Yes | Your MPI's identifier for the authentication |
| `cavv` | Yes | Cardholder Authentication Verification Value |
| `xid` | 3DS1 only | Transaction identifier |
| `eci_raw` | Yes | Electronic Commerce Indicator — see below |
| `is_recurring` | For MIT | Flags the charge as merchant-initiated |
| `previous_network_transaction_id` | For MIT | Links this charge to the transaction that established the credential |

### ECI values [#eci-values]

| ECI | Meaning | Liability shift |
|---|---|---|
| `05` | Fully authenticated (Visa) | Yes |
| `02` | Fully authenticated (Mastercard) | Yes |
| `06` | Authentication attempted (Visa) | Conditional |
| `01` | Authentication attempted (Mastercard) | Conditional |
| `07` | Not authenticated (Visa) | No |
| `00` | Not authenticated (Mastercard) | No |

This flow must run against a brand configured for external 3D Secure. Contact your account manager before building it.

## Flow 8 — External network tokens [#flow-8--external-network-tokens]

If you provision your own network tokens from Visa Token Service or Mastercard
Digital Enablement Service, present the token and its cryptogram in place of a
PAN. The token goes in `number`, exactly where a PAN would.

```json
"purchase": {
  "currency": "ZAR",
  "products": [ { "name": "My product or service", "price": 10000 } ],
  "payment_method_details": {
    "card": {
      "number": "4567861893990640",
      "cryptogram": "dynamic-cryptogram-from-the-scheme",
      "auth_brand": "MASTERCARD",
      "card_type": "001",
      "transaction_type": "3",
      "xid": "",
      "is_recurring": true,
      "previous_network_transaction_id": "016153570198200",
      "original_amount_cents": "10000"
    }
  }
}
```

| Field | Required | Notes |
|---|---|---|
| `number` | Yes | The network token, in place of the PAN |
| `cryptogram` | Yes | The dynamic cryptogram issued with the token |
| `auth_brand` | Yes | `VISA` or `MASTERCARD` |
| `card_type` | Yes | Scheme card type code, for example `001` |
| `transaction_type` | Yes | Scheme transaction type code, for example `3` |
| `xid` | 3DS1 only | Send an empty string when not applicable |
| `is_recurring` | For MIT | Flags the charge as merchant-initiated |
| `previous_network_transaction_id` | For MIT | Links this charge to the transaction that established the credential |
| `original_amount_cents` | For MIT | The amount of the original transaction, in cents |

Network tokens generally improve authorisation rates and remove the need to
handle card expiry updates. Use a **non-3DS Brand** for merchant-initiated
charges on this flow.

## Restricting payment methods [#restricting-payment-methods]

Use `payment_method_whitelist` to limit what a purchase will accept. Some capabilities require exactly one method, so set it deliberately rather than leaving it open.

```bash
"payment_method_whitelist": ["visa", "mastercard", "maestro"]
```

Card methods include `visa`, `mastercard`, `maestro`, `american_express` and `diners_club`. The gateway also carries non-card methods — instant EFT, DebiCheck, debit orders and a range of pan-African rails — which are documented under *Payment Orchestration*.

Instant EFT has two providers, and the identifier is the only thing that differs:

| Provider | `payment_method_whitelist` value |
|---|---|
| Ozow | `ozow` |
| OneGate | `onegate_eft_redirect` |


Rather than hard-coding a list, ask the API what a brand actually has enabled:

```bash
GET https://gate.reviopay.com/api/v1/payment_methods/?brand_id={brand_id}&currency=ZAR
```

Send it with the same key you will create the purchase with — the key determines whether the lookup runs against test or live configuration.

## Refunds [#refunds]

```bash
POST https://gate.reviopay.com/api/v1/purchases/{id}/refund/
{ "amount": 50000 }
```

Omit `amount` for a full refund. Partial refunds may be repeated until the original total is exhausted. Check `refund_availability` and `refundable_amount` on the purchase first — not every payment method supports partial refunds.

<Callout type="important">
**A refund cannot be voided.** Once submitted it runs to completion; there is no reversal. See *Advanced refund management* for the full state model.
</Callout>

## Cancelling a purchase [#cancelling-a-purchase]

```bash
POST https://gate.reviopay.com/api/v1/purchases/{id}/cancel/
```

Cancels a purchase that has not been paid. Use `/release/` for an authorisation on hold, and `/refund/` once a payment has completed.

## Webhooks [#webhooks]

Webhooks are the authoritative record of what happened. Register once and treat the callback as the trigger for fulfilment.

```bash
POST https://gate.reviopay.com/api/v1/webhooks/
{
  "title": "Order events",
  "callback": "https://www.example.com/webhooks/precium",
  "events": ["purchase.paid", "purchase.payment_failure", "payment.charged_back"]
}
```

Pass `all_events: true` instead of `events` to subscribe to everything. Test and live webhooks are separate: a test webhook never receives events from live purchases.

### Available events [#available-events]

| Group | Events |
|---|---|
| **Purchase lifecycle** | `purchase.created`, `purchase.viewed`, `purchase.paid`, `purchase.payment_failure`, `purchase.cancelled` |
| **Authorisation** | `purchase.hold`, `purchase.preauthorized`, `purchase.captured`, `purchase.capture_failure`, `purchase.released`, `purchase.release_failure` |
| **In flight** | `purchase.pending_charge`, `purchase.pending_capture`, `purchase.pending_release`, `purchase.pending_refund`, `purchase.pending_execute` |
| **Refunds and disputes** | `payment.refunded`, `purchase.refund_failure`, `payment.charged_back`, `payment.chargeback_reversed` |
| **Settlement** | `purchase.settled` |
| **Tokens** | `purchase.recurring_token_deleted`, `purchase.pending_recurring_token_delete` |

Further events exist for payouts and subscription billing; those products are documented separately and are not part of the S2S card flow.

### Verifying a callback [#verifying-a-callback]

Payloads are signed with asymmetric public-key cryptography. Each delivery carries an `X-Signature` header: a base64-encoded **RSA PKCS#1 v1.5** signature of the **SHA-256 digest of the raw request body**.

| Callback | Public key from |
|---|---|
| Webhook subscription | `public_key` on the webhook object |
| Per-purchase `success_callback` | `GET /public_key/` |

The key is a PEM-encoded RSA public key. Verify against the raw body bytes, before parsing or re-serialising — reformatting the JSON invalidates the signature. Precium is not responsible for losses arising from unverified payloads.

### Delivery and retries [#delivery-and-retries]

- Only a 2xx counts as delivered. Failures retry up to **8 further times** at exponentially increasing intervals.
- Nothing is attempted more than **36 hours** after the triggering event.
- **Duplicates are possible even after a 200**, if the confirmation is lost. Make handlers idempotent, keyed on the event and object id.
- Delivery is ordered per object: no `purchase.paid` for a purchase until all its `purchase.created` callbacks have been delivered.

### Inspecting delivery history [#inspecting-delivery-history]

```bash
GET https://gate.reviopay.com/api/v1/webhooks/deliveries/?id={object_id}&source_type=Purchase
```

Returns every attempt for that object — successes, failures and retries. Both parameters are required. This is the first place to look when a callback appears not to have arrived.

## Duplicate protection [#duplicate-protection]

There is no idempotency key. Duplicates are prevented by state:

- A purchase **may be paid only once**; repeat attempts are rejected, typically with `409 Conflict`
- Client email addresses are unique
- Any number of purchases may exist for one client, each independently payable once

So retry against the *same* purchase id rather than creating a new purchase, and confirm state with `GET /purchases/{id}/` before retrying. Creating a fresh purchase on every retry is what produces double charges.

## Rate limits and pagination [#rate-limits-and-pagination]

The default limit is **10 transactions per second, per IP address**. Exceeding it returns `429`; back off and retry.

Before go-live, send Precium the production IP addresses that will call the API and receive webhooks, together with the throughput you need. Those IPs are allow-listed and capacity is provisioned against the agreed figure. See [Going live](/docs/server-to-server/going-live).

List endpoints are cursor-paginated as `results` with `next` and `previous`. Follow `next`; do not build offsets.

## Settlement [#settlement]

Transactions are authorised and settled the next day. Settlement of funds to your account may follow a different schedule depending on your commercial model — confirm your cycle with your account manager. Reconciliation file formats are in *Reconciliation*.

## Related pages [#related-pages]

- *Technical reference documentation* — the full endpoint and field reference
- *Error mapping* — every error code with cause and retry guidance
- *Issuer response codes* — decline codes returned by issuing banks
- *Advanced refund management* — the complete refund state model
- *Test scenarios* — the full pre-launch test matrix
