This guide is for merchants integrating a store, app, or backoffice with Sikina Pay using hosted web checkout. You create a payment link from your server, redirect the customer to it, and Sikina Pay hosts the checkout, processes the payment, and notifies your server via a signed webhook.
You do not implement any payment-method-specific screens (USSD, OTP, cards). Sikina Pay handles all of that on its hosted checkout page.
01Create link
POST /generatePaymentLink returns a paymentUrl for the hosted checkout.
02Hosted checkout
The customer picks a method, completes OTP/USSD, and is redirected back to your site.
03Signed webhook
A signed POST to your webhook URL confirms the final outcome. Trust this — not the redirect.
Three steps: (1) your server creates a link; (2) you redirect the customer to it; (3) Sikina Pay posts a signed webhook confirming the outcome. The webhook — not the browser redirect — is what you trust.
- A verified business account on the merchant dashboard. Live credentials only work for verified businesses.
- API credentials. Generate a test key (sandbox) and a live key (production) from the developer section of the dashboard.
- A webhook URL and webhook secret, configured in the dashboard. You cannot create payment links until a webhook secret is set.
- Redirect URLs — one for success, one for failure. Both must use
https://. - Optionally, allowlist your server's outbound IPs in the dashboard. Once any IP is allowlisted, other IPs are rejected.
Keep secrets on the server
| Environment | Use with | Base URL |
|---|---|---|
| Sandbox | testKey | https://sandbox.sikinapay.com |
| Production | liveKey | https://api.sikinapay.com |
Use sandbox until your integration is complete, then swap the base URL and the key.
Every API call carries your secret key in the Authorization header:
Authorization: Bearer <your-secret-key>- One key per environment — test in sandbox, live in production.
- Never expose the key from a browser, mobile app, or client-side bundle.
- Rotate any time from the dashboard. The previous key stops working immediately.
- After 10 rejected calls with a bad key from the same IP, the IP is temporarily blocked. Fix the key rather than retrying.
The end-to-end lifecycle of a hosted checkout payment:
01Create link
POST /generatePaymentLink returns a paymentUrl for the hosted checkout.
02Hosted checkout
The customer picks a method, completes OTP/USSD, and is redirected back to your site.
03Signed webhook
A signed POST to your webhook URL confirms the final outcome. Trust this — not the redirect.
Sequence
[Your server] [Sikina Pay] [Customer browser]
│ │ │
│ 1. POST │ │
│ /generatePaymentLink │ │
│─────────────────────────►│ │
│ ◄─────── paymentUrl ────│ │
│ │ │
│ 2. redirect ────────────┼────────────────────────────►│
│ │ hosted checkout page │
│ │◄────────────────────────────│
│ │ processes payment │
│ │ ────────────────────────► │
│ │ redirects to success/fail │
│ │────────────────────────────►│
│ │ │
│ 3. POST signed webhook │ │
│◄─────────────────────────│ │
│ 200 OK ────────────────►│ │/api/v1/gateway/generatePaymentLink| Field | Type | Required | Description |
|---|---|---|---|
amount | number | yes | Amount to charge, in the platform currency. Must be at least the platform minimum. |
clientReferenceId | string, ≤128 chars | yes | Your own reference for this payment. Must be unique across all payments for your business. This is your idempotency key. |
description | string, ≤64 chars | yes | Shown to the customer on the checkout page (for example, "Order #4711"). |
paymentSuccessfulRedirectUrl | string, https:// | if no default | Where the customer's browser lands after a successful payment. |
paymentFailedRedirectUrl | string, https:// | if no default | Where the customer's browser lands after a failed payment. |
webhookUrl | string, https:// | optional | Overrides the business-level webhook URL for this one payment. |
language | string | optional | Preferred language code (must be one of the platform's supported languages). |
commissionPaidByCustomer | boolean | optional | When true, the provider commission is added on top of amount and paid by the customer. Defaults to false. |
import fetch from "node-fetch";
const res = await fetch("https://api.sikinapay.com/api/v1/gateway/generatePaymentLink", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SIKINA_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: 250.00,
clientReferenceId: "order-2026-07-10-4711",
description: "Order #4711",
paymentSuccessfulRedirectUrl: "https://shop.example.com/checkout/success",
paymentFailedRedirectUrl: "https://shop.example.com/checkout/failed",
language: "en",
}),
});
const { paymentUrl } = await res.json();
// 303 redirect the customer to paymentUrlSuccessful response:
{
"responseCode": "0",
"responseStatus": "Success",
"responseMessage": "Success",
"paymentUrl": "https://api.sikinapay.com/checkout/web/pi_01HZ...",
"currentDate": "2026-07-10",
"currentTime": "12:34:56"
}Persist before redirecting
clientReferenceId and the intent id embedded in paymentUrl in your database before redirecting. If the tab closes or the network drops, you still need to know what the customer was paying for.- All URL fields must use
https://and cannot point at private addresses. descriptionaccepts letters, digits, punctuation, and spaces. HTML and script-like input is rejected.- Null bytes, control characters, and invisible characters are stripped from all strings; markup is rejected.
clientReferenceIdmust be unique. Reusing one returns a duplicate error — see Idempotency.
Redirect the customer's browser to the returned paymentUrl:
HTTP/1.1 303 See Other
Location: https://api.sikinapay.com/checkout/web/pi_01HZ...Sikina Pay hosts the entire checkout — payment-method selection, OTP/USSD prompts, success and failure. When done, the gateway redirects the browser to your success or failure URL and appends the payment intent id and your clientReferenceId as query parameters.
Do not update your database from the redirect
getPaymentStatus call.When a payment reaches a final state, Sikina Pay sends a signed POST to your webhook URL. This is the authoritative outcome.
Payload
{
"eventId": "evt_01HZ...",
"eventType": "payment.successful",
"createdAt": "2026-07-10T12:35:14Z",
"paymentReferenceId": "pi_01HZ...",
"clientReferenceId": "order-2026-07-10-4711",
"amount": 250.00,
"currency": "ETB",
"data": {}
}| Field | Type | Required | Description |
|---|---|---|---|
eventId | string | yes | Unique id for this event. Use it to deduplicate — the gateway will retry. |
eventType | enum | yes | See the payment / subscription event tables. |
createdAt | ISO-8601 UTC | yes | When the event was produced by the gateway. |
paymentReferenceId | string | yes | The payment intent id (same one that was in paymentUrl). |
clientReferenceId | string | yes | Your reference — the one you sent when creating the link. |
amount | number | yes | The amount that was (or would have been) charged. |
currency | string | yes | ISO currency code (e.g. ETB). |
data | object | yes | Extra structured detail. Empty for most payment events. |
Payment event types
| eventType | Meaning |
|---|---|
payment.successful | Payment completed. Ship the goods. |
payment.failed | Payment was attempted and refused. |
payment.expired | The link expired without the customer paying. |
payment.cancelled | The customer cancelled during checkout. |
payment.revoked | The payment was revoked after the fact. |
Signature header
SikinaPay-Signature: t=<unix-seconds>,v1=<hex-hmac-sha256>How to verify
- Read the raw request body before parsing JSON — signature verification uses the exact bytes.
- Parse
t(timestamp) andv1(signature) from the header. - Reject if
now - tis outside your tolerance (5 minutes is a good default). - Recompute
signedContent = t + "." + raw_body, thenexpected = hex(HMAC_SHA256(webhook_secret, signedContent)). - Compare
expectedwithv1using a constant-time comparison (hmac.compare_digest,crypto.timingSafeEqual,hash_equals). - If the check passes, process the event. If not, respond
400and log.
import crypto from "node:crypto";
export function verifySikinaWebhook(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=")),
);
const t = Number(parts.t);
const v1 = parts.v1;
if (!t || !v1) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(v1, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Responding
- Respond
2xxas soon as you have persisted the event id and enqueued work. Fulfil asynchronously. - Any non-
2xxresponse or a timeout causes retries for several hours. Design your handler to be idempotent.
Deduplication
- Check whether you have already processed this
eventId. If yes, respond200and stop. - Otherwise, act, then record the
eventId.
Also track paymentReferenceId — once you record a terminal outcome for that payment, ignore later events for it.
Even with retries, webhooks can fail to reach you (long outage, misconfiguration, DNS). Reconcile using the status API.
Query status on demand
/api/v1/gateway/getPaymentStatusconst res = await fetch("https://api.sikinapay.com/api/v1/gateway/getPaymentStatus", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SIKINA_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
clientReferenceId: "order-2026-07-10-4711",
transactionDate: "2026-07-10",
}),
});
const status = (await res.json()).responseMessage;
// PENDING | SUCCESSFUL | FAILED | EXPIRED | CANCELLED | REVOKEDThe responseMessage field reports the current status: PENDING, SUCCESSFUL, FAILED, EXPIRED, CANCELLED, REVOKED. Anything other than PENDING is final.
Nightly reconcile job
getPaymentStatus. This closes the loop on any webhook that never arrived.clientReferenceId is your idempotency key.
- Generate one unique value per payment attempt (a UUID, or
order-<id>-<attempt>). - Keep it stable across network retries of the same operation — a retried "create link" call must not create two links.
- Do not reuse across different payments. Reusing returns a duplicate error.
- Use the same value in
getPaymentStatusto reconcile.
On network errors
generatePaymentLink times out, retry with the same clientReferenceId. If a link was already created you will get a duplicate error and can query status; if not, the retry is safe.Subscriptions use the same hosted checkout flow as one-time payments. You create a link with a recurrence configuration; the customer pays the first charge on the hosted page; Sikina Pay then bills them automatically on your schedule and sends a webhook for each cycle.
Create a subscription link
/api/v1/gateway/generateSubscriptionPaymentLinkSame fields as generatePaymentLink, plus a required subscriptionConfiguration:
| Field | Type | Required | Description |
|---|---|---|---|
subscriptionConfiguration.interval | enum | yes | Recurrence unit: DAY, MONTH, or YEAR. |
subscriptionConfiguration.intervalCycle | integer, 1–365 | yes | Number of interval units between charges. MONTH+1 = monthly; MONTH+3 = quarterly. |
subscriptionConfiguration.totalCycles | integer, ≥1 | optional | Cap on total charges (including the first). Omit for open-ended. |
subscriptionConfiguration.customerEmail | string, ≤256 chars | optional | Customer email — used for receipts. |
subscriptionConfiguration.customerPhone | string, ≤32 chars | optional | Customer phone in international format. |
const res = await fetch("https://api.sikinapay.com/api/v1/gateway/generateSubscriptionPaymentLink", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SIKINA_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: 19.99,
clientReferenceId: "sub-2026-07-10-4711",
description: "Premium plan (monthly)",
paymentSuccessfulRedirectUrl: "https://shop.example.com/subscribed",
paymentFailedRedirectUrl: "https://shop.example.com/checkout/failed",
subscriptionConfiguration: {
interval: "MONTH",
intervalCycle: 1,
totalCycles: 12,
customerEmail: "[email protected]",
},
}),
});
const { paymentUrl } = await res.json();Lifecycle
Subscription events
| eventType | When it fires | data fields |
|---|---|---|
subscription.activated | The first charge succeeded and the subscription is now active. | subscriptionId, clientSubscriptionId, status: ACTIVE, currentCycleCount, nextPaymentDate |
subscription.renewed | A subsequent cycle was charged successfully. | Same as above, with the new currentCycleCount and nextPaymentDate. |
subscription.payment_due | The next charge is about to be attempted. | status: PAYMENT_PENDING, currentCycleCount, nextPaymentDate |
subscription.cancelled | Cancelled by the merchant, customer, or the gateway. No further charges. | status: CANCELLED, cancellationReason |
subscription.expired | The subscription reached totalCycles and completed naturally. | status: EXPIRED, currentCycleCount |
Example subscription.renewed payload:
{
"eventId": "evt_01HZ...",
"eventType": "subscription.renewed",
"createdAt": "2026-08-10T00:01:15Z",
"clientReferenceId": "sub-2026-07-10-4711",
"amount": 19.99,
"currency": "ETB",
"data": {
"subscriptionId": "sub_01HZ...",
"clientSubscriptionId": "sub-2026-07-10-4711",
"status": "ACTIVE",
"currentCycleCount": 2,
"nextPaymentDate": "2026-09-10"
}
}Cancel a subscription
/api/v1/gateway/cancelSubscriptionawait fetch("https://api.sikinapay.com/api/v1/gateway/cancelSubscription", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SIKINA_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ subscriptionId: "sub_01HZ..." }),
});What merchants should implement
- Persist
subscriptionIdandclientSubscriptionIdonsubscription.activatedand map them to the customer. - Grant access on
subscription.activated; extend onsubscription.renewed. - Revoke access on
subscription.cancelledandsubscription.expired. - Treat
subscription.payment_dueas informational — actual grant/revoke decisions come from the events that follow. - Handle duplicate deliveries via
eventId, exactly as for payment events.
Testing
- Point at the sandbox base URL
https://sandbox.sikinapay.comand use yourtestKey. - Trigger every event type at least once against a real endpoint. Use the dashboard's send test webhook tool to fire a signed event and verify your signature check before going live.
- Test negative paths: failed payments, cancelled payments, expired links, duplicate
clientReferenceId. - Confirm your webhook handler is idempotent — send it the same event twice.
- Confirm unsigned or tampered payloads are rejected.
Tick each box before flipping to live. Progress is saved to your browser.
- Business is verified on the dashboard.
- Live API credentials generated.
- Webhook URL points to a public HTTPS endpoint under your control.
- Webhook secret is set in the dashboard.
- Signature verification uses constant-time comparison and rejects timestamps outside a 5-minute window.
- clientReferenceId is unique per payment and used consistently on retries and status queries.
- Order state is updated only from the webhook (or from an explicit getPaymentStatus call).
- Webhook handler is idempotent and returns 2xx quickly.
- Nightly reconcile job in place.
- Optionally, production server IPs added to the IP allowlist.
- Base URL and key swapped from sandbox to live.
0 / 11 complete
Support
If something looks wrong that this guide does not cover, contact us with:
- The
paymentReferenceIdandclientReferenceIdinvolved. - The exact request and response (with the
Authorizationheader redacted). - The
currentDateandcurrentTimefrom the response, or your own request timestamp.