NGN Card Payments
Use the SaySwitch Server-to-Server (S2S) API to accept NGN card payments from your secure backend. Never call these endpoints from browser code because every request requires your secret key.
The flow can finish immediately or require another action. Always inspect the initialize response and follow the URL, method, and payload contract returned for that transaction.
Requirements
- A SaySwitch secret key for the correct test or live environment
- An active NGN wallet in the same environment
- A unique transaction reference containing 16 to 33 characters
- Card details collected and handled in a PCI DSS-compliant environment
The default initialize endpoint settles into your active default NGN wallet. Do not send wallet_code to it. To select a specific NGN wallet, see Card Payment With Wallet.
Endpoints
| Action | Method | Endpoint |
|---|---|---|
| Encrypt card details | POST | https://backendapi.sayswitchgroup.com/api/s2s/v1/test/encryption |
| Initialize payment | POST | https://backendapi.sayswitchgroup.com/api/s2s/v1/transaction/initialize |
| Continue authentication | Returned by initialize | Follow the returned authentication URL and method |
| Verify transaction | GET | https://backendapi.sayswitchgroup.com/api/s2s/v1/transaction/verify/{reference} |
Reference Requirement
Use the exact same reference for every stage of one payment:
- Card encryption
- Payment initialization
- OTP or 3DS continuation
- Transaction verification
The reference is used to derive the card-encryption initialization vector. A different reference causes card decryption to fail.
For the complete flow, the reference must be a string from 16 to 33 characters and must be unique for your merchant account and environment. The examples below use CARD_PAYMENT_2026_0001 throughout.
1. Encrypt Card Details
curl --request POST \
--url https://backendapi.sayswitchgroup.com/api/s2s/v1/test/encryption \
--header "Authorization: Bearer YOUR_SECRET_KEY" \
--header "Content-Type: application/json" \
--data '{
"data": {
"number": "4005555555000009",
"expiryMonth": "05",
"expiryYear": "37",
"cvv": "000"
},
"reference": "CARD_PAYMENT_2026_0001"
}'Encryption Fields
| Field | Type | Required | Description |
|---|---|---|---|
data.number | string | Yes | Card number. Initialization accepts 14 or more digits. |
data.expiryMonth | string | Yes | Two-digit expiry month. |
data.expiryYear | string | Yes | Two-digit expiry year. |
data.cvv | string | Yes | Three- or four-digit card security code. |
reference | string | Yes | The same 16–33 character reference used for initialization. |
Encryption Response
The endpoint returns the encrypted hexadecimal string directly in the response body. It does not return a JSON object.
8f2a...illustrative_hexadecimal_value...91bcRead the response as text. The framework may label this raw text response as text/html; charset=UTF-8; the body itself is the encrypted hexadecimal card payload.
This value is not a reusable card token. Keep it only for the active transaction and any continuation step that explicitly asks for it. Never log or permanently store the card number, CVV, PIN, or encrypted card payload.
Server-Side Node.js Example
This code runs on your server with Node.js 18 or later. Do not place it in frontend JavaScript.
const reference = 'CARD_PAYMENT_2026_0001';
const encryptResponse = await fetch(
'https://backendapi.sayswitchgroup.com/api/s2s/v1/test/encryption',
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SAYSWITCH_SECRET_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
data: {
number: '4005555555000009',
expiryMonth: '05',
expiryYear: '37',
cvv: '000'
},
reference
})
}
);
if (!encryptResponse.ok) {
throw new Error(await encryptResponse.text());
}
const encryptedCardHex = await encryptResponse.text();2. Initialize the Payment
curl --request POST \
--url https://backendapi.sayswitchgroup.com/api/s2s/v1/transaction/initialize \
--header "Authorization: Bearer YOUR_SECRET_KEY" \
--header "Accept: application/json" \
--header "Content-Type: application/json" \
--data '{
"amount": "1000.00",
"card": "ENCRYPTED_CARD_HEX_STRING",
"currency": "NGN",
"email": "customer@example.com",
"reference": "CARD_PAYMENT_2026_0001"
}'Initialize Fields
| Field | Type | Required | Description |
|---|---|---|---|
amount | string | Yes | Amount to collect. The value must be at least 1 and within your transaction and collection limits. |
card | string | Yes | Raw hexadecimal response from the encryption endpoint. |
currency | string | No | Use NGN. If omitted, the API defaults to NGN. |
email | string | Yes | Valid customer email address, at least six characters. |
reference | string | Yes | Unique 16–33 character reference used during encryption. |
pin | string | Sometimes | Top-level card PIN, at least four characters, when required by the routed card flow. |
status | string | Sandbox only | Optional forced result: success, failed, or pending. |
Do not put pin inside the encrypted card object. The current S2S initialize controller reads PIN from the top-level pin field. Visa may not require a PIN in the current NGN flow; Mastercard, Verve, or another routed flow may require one.
The initialize response determines what happens next. Do not assume that every NGN payment requires OTP.
3. Handle the Initialize Response
Possible outcomes include immediate success, failure, OTP required, 3DS required, or processing. Response messages and authentication details vary by the route selected for the card.
Immediate Success
{
"status": true,
"message": "Payment is successful"
}Another successful route may return "Payment Successful". In either case, verify the transaction before fulfilling the order.
Failure
{
"status": false,
"message": "Test payment declined (simulated)"
}This is the built-in sandbox failure response. Live decline messages reflect the actual result returned for the transaction.
Processing
The sandbox forced-pending flow returns:
{
"status": true,
"message": "Payment Processing"
}Treat this as pending and verify again later or wait for the webhook.
OTP Required
The NGN OTP response has this structure. Values below are illustrative placeholders.
{
"status": true,
"message": "Please enter transaction OTP sent to your phone number/email or enter OTP from your hardware/softtoken to complete the transaction",
"reference": "CARD_PAYMENT_2026_0001",
"data": {
"paymentid": "PAYMENT_ID_RETURNED_BY_SAYSWITCH",
"supportMessage": "Please enter transaction OTP sent to your phone number/email or enter OTP from your hardware/softtoken to complete the transaction",
"_links": {
"url": "RETURNED_OTP_CONTINUATION_URL",
"method": "POST",
"payload": ["otp, ref, payid"]
}
}
}Ask the customer to enter the OTP in your interface. Your secure backend must then submit these fields to the exact data._links.url using data._links.method:
| Field | Value |
|---|---|
otp | OTP entered by the customer |
ref | CARD_PAYMENT_2026_0001 |
payid | Value from data.paymentid |
{
"otp": "CUSTOMER_OTP",
"ref": "CARD_PAYMENT_2026_0001",
"payid": "PAYMENT_ID_RETURNED_BY_SAYSWITCH"
}Do not redirect the customer to an invented OTP page and do not hardcode an opaque continuation route. Follow the URL and method returned for the transaction.
A successful OTP continuation returns:
{
"status": true,
"message": "Payment Successful"
}An invalid OTP returns status: false with the message returned for that attempt.
3DS Required
The current NGN 3DS response uses separate customer and server actions:
{
"status": true,
"message": "Proceed authentication",
"data": {
"threed": {
"TermUrl": "RETURNED_TERM_URL",
"MD": "RETURNED_MD_VALUE",
"ACSUrl": "RETURNED_ACS_URL",
"jwt": "RETURNED_JWT_VALUE",
"paymentId": "PAYMENT_ID_RETURNED_BY_SAYSWITCH",
"transactionId": "TRANSACTION_ID_RETURNED_BY_SAYSWITCH",
"eciFlag": "RETURNED_ECI_FLAG"
},
"authenticate_payer": {
"_link": "RETURNED_CUSTOMER_AUTHENTICATION_URL",
"_action": "web visit"
},
"validate_authentication": {
"_link": "RETURNED_VALIDATION_URL",
"_action": "api",
"_method": "GET"
}
}
}Open data.authenticate_payer._link in the customer’s browser. It renders the cardholder authentication step using the returned threed values. After the customer completes that step, your backend can call data.validate_authentication._link using its returned GET method to finalize the authentication.
Do not substitute the generic /transaction/card/3ds endpoint unless the response for that transaction explicitly returns it. The rule is simple: always follow the authentication link and method returned in the initialize response.
4. Verify the Transaction
curl --request GET \
--url https://backendapi.sayswitchgroup.com/api/s2s/v1/transaction/verify/CARD_PAYMENT_2026_0001 \
--header "Authorization: Bearer YOUR_SECRET_KEY" \
--header "Accept: application/json"The following example preserves the controller response structure. IDs, timestamps, card metadata, fees, and token values are illustrative or redacted.
{
"success": true,
"message": "Verification successful",
"data": {
"amount": "1015.00",
"currency": "NGN",
"status": "success",
"transaction_date": "2026-09-25T10:31:12.000000Z",
"reference": "CARD_PAYMENT_2026_0001",
"domain": "test",
"gateway_response": null,
"channel": "card",
"ip_address": "192.0.2.10",
"originator_name": "",
"originator_account_number": "",
"fees": "15.00",
"plan": null,
"requested_amount": "1000.00"
},
"customer": {
"id": 4512,
"customer_code": "CUS_ILLUSTRATIVE",
"first_name": null,
"last_name": null,
"email": "customer@example.com"
},
"card": {
"first6Digits": "400555",
"last4Digits": "0009",
"expiry": "0537",
"type": "visa",
"token": "REDACTED"
},
"log": {
"time_spent": 0,
"attempts": 0,
"authentication": null,
"errors": 1,
"success": true,
"channel": "card",
"history": []
}
}Only fulfil the order when the verified data.status is success. An initialize response, OTP result, 3DS redirect, return URL, or webhook receipt should not replace final server-side verification.
If the reference does not exist for the authenticated merchant and environment, the endpoint returns HTTP 401:
{
"success": false,
"message": "Reference code does not exist"
}Sandbox Cards
Use the normal NGN cards listed on Sandbox Test Cards. Encrypt the selected card before initialization and use the same reference for both calls.
See Sandbox Test Mode for complete examples and forced success, failure, and pending outcomes.
Errors
| Condition | HTTP behavior | Response message or shape |
|---|---|---|
| Missing or invalid merchant authorization | 401 | Merchant Authorization is required or Invalid Secret Key |
| Invalid initialize fields or reference length | 400 | status: false, validation message, and error object |
| Invalid hexadecimal card value or mismatched reference | 200 with status: false | Kindly check your encrypted value |
| Incomplete decrypted card fields | 400 | Incomplete card request |
| Duplicate merchant reference | 200 with status: false | Reference Already Exist |
| No active wallet for the currency and environment | 400 | Unauthorized Merchant Currency provided |
| Transaction limit exceeded | 400 | Transaction limit exceeded. Maximum allowed amount for NGN is ... per transaction. |
| Collection amount outside configured limits | 400 | Minimum or maximum collection-limit message |
| Invalid OTP | 200 with status: false | Message returned for the OTP attempt |
| Missing OTP fields | 200 with status: false | Request aborted. with validation errors |
| Transaction reference not found during verification | 401 | Reference code does not exist |
Some payment-route errors are returned with HTTP 200 and status: false. Always inspect both the HTTP status and JSON body.
Full API Payload Encryption
Card-field encryption and full API payload encryption are separate layers:
- Card-field encryption creates the hexadecimal value sent as
card. - Merchants specially configured for full S2S payload encryption must then place the complete API request inside the required encrypted
dataenvelope. Their API responses can also be returned as encrypted response bodies.
The examples on this page show the standard request format. If full payload encryption is enabled for your account, follow API Payload Encryption for the outer request and response layer.
Security Notes
- Make every S2S call from your secure backend.
- Keep your secret key in an environment variable or secret manager.
- Never log or permanently store PAN, CVV, PIN, or encrypted card payloads.
- Do not expose your secret key or card details in browser JavaScript.
- Discard the encrypted card payload after the transaction and required continuations finish.
Legacy Endpoints
Existing integrations using /api/s2s/... can continue using the legacy family. New integrations should use /api/s2s/v1/....
| Action | Legacy endpoint |
|---|---|
| Encrypt card details | POST /api/s2s/test/encryption |
| Initialize payment | POST /api/s2s/transaction/initialize |
| OTP continuation, when returned | POST /api/s2s/transaction/card/otp |
| 3DS continuation, when returned | POST /api/s2s/transaction/card/3ds |
| Verify transaction | GET /api/s2s/transaction/verify/{reference} |
Do not mix the /api/s2s/... and /api/s2s/v1/... endpoint families during one transaction. Use one family for encryption, initialization, any family-based continuation link, and verification. Always follow transaction-specific links returned by initialize. There is no public /api/s2s/v2/... endpoint.