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.
| Key | Used for |
|---|---|
| Standard API key | Every API operation: clients, purchases, charge, capture, release, refund, cancel, webhooks, reporting |
| S2S API key | Submitting card data to a purchase's direct_post_url |
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.
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.
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 |
Create the purchase and send the customer to checkout_url. Precium hosts the card form, so you handle no card data.
POST /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"
} 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.
<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 the same fields as JSON to {direct_post_url}?s2s=true using your S2S key. That is the full S2S flow and requires SAQ-D.
Verify a card and store it without moving money. Set skip_capture: trueand a purchase total of zero.
POST /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.
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.
POST /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.
POST /purchases/{id}/release/ Releases the hold without taking payment.
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.
Store a card on an authenticated first payment, then charge it later without the cardholder present.
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.
Create a new purchase for the new amount, then charge it against the original:
POST /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.
GET /clients/{id}/recurring_tokens/
DELETE /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.
For a charge with no cardholder present, flag it as recurring and reference the prior transaction so the issuer can link the two.
"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 |
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.
If you run your own Merchant Plug-In, authenticate first and pass the results on the purchase.
"payment_method_details": {
"card": {
"is_external_3DS": true,
"authentication_transaction_id": "your-mpi-transaction-id",
"cavv": "base64-cardholder-authentication-value",
"xid": "3ds1-transaction-identifier",
"eci_raw": "05"
}
} | 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 |
| 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.
If you provision your own tokens from Visa Token Service or Mastercard Digital Enablement Service, present the token and its cryptogram instead of a PAN.
"payment_method_details": {
"card": {
"network_token": "your-network-token",
"network_token_cryptogram": "dynamic-cryptogram",
"token_requestor_id": "your-token-requestor-id",
"is_recurring": true
}
} All three token fields are required. Network tokens generally improve authorisation rates and remove the need to handle card expiry updates.
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.
"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.
Rather than hard-coding a list, ask the API what a brand actually has enabled:
GET /payment_methods/?brand_id={brand_id}¤cy=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.
POST /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.
A refund cannot be voided. Once submitted it runs to completion; there is no reversal. See Advanced refund management for the full state model.
POST /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 are the authoritative record of what happened. Register once and treat the callback as the trigger for fulfilment.
POST /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.
| 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.
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.
purchase.paid for a purchase until all its purchase.created callbacks have been delivered.GET /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.
There is no idempotency key. Duplicates are prevented by state:
409 ConflictSo 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.
100 requests per minute, bursting to 200. Responses carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; back off on 429.
List endpoints are cursor-paginated as results with next and previous. Follow next; do not build offsets.
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.