Developers

Sikina Pay integration guide

Ship payments in a day. Create a payment link from your server, redirect your customer to hosted checkout, and receive a signed webhook when it's done — no payment-method screens to build, no PCI scope to worry about.

Base URL https://api.sikinapay.comSandbox https://sandbox.sikinapay.comHeader SikinaPay-Signature

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.

Your server

01Create link

POST /generatePaymentLink returns a paymentUrl for the hosted checkout.

Sikina Pay

02Hosted checkout

The customer picks a method, completes OTP/USSD, and is redirected back to your site.

Your server

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.

  1. A verified business account on the merchant dashboard. Live credentials only work for verified businesses.
  2. API credentials. Generate a test key (sandbox) and a live key (production) from the developer section of the dashboard.
  3. A webhook URL and webhook secret, configured in the dashboard. You cannot create payment links until a webhook secret is set.
  4. Redirect URLs — one for success, one for failure. Both must use https://.
  5. Optionally, allowlist your server's outbound IPs in the dashboard. Once any IP is allowlisted, other IPs are rejected.
Warning

Keep secrets on the server

Secret keys and the webhook secret must never appear in a browser, mobile app, or any customer-visible artefact.
EnvironmentUse withBase URL
SandboxtestKeyhttps://sandbox.sikinapay.com
ProductionliveKeyhttps://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:

HTTP
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:

Your server

01Create link

POST /generatePaymentLink returns a paymentUrl for the hosted checkout.

Sikina Pay

02Hosted checkout

The customer picks a method, completes OTP/USSD, and is redirected back to your site.

Your server

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 ────────────────►│                             │

Redirect the customer's browser to the returned paymentUrl:

HTTP
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.

Warning

Do not update your database from the redirect

The redirect is not authenticated — anyone can craft that URL. Update state only from the webhook, or after an explicit 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

JSON
{
  "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": {}
}
FieldTypeRequiredDescription
eventIdstringyesUnique id for this event. Use it to deduplicate — the gateway will retry.
eventTypeenumyesSee the payment / subscription event tables.
createdAtISO-8601 UTCyesWhen the event was produced by the gateway.
paymentReferenceIdstringyesThe payment intent id (same one that was in paymentUrl).
clientReferenceIdstringyesYour reference — the one you sent when creating the link.
amountnumberyesThe amount that was (or would have been) charged.
currencystringyesISO currency code (e.g. ETB).
dataobjectyesExtra structured detail. Empty for most payment events.

Payment event types

eventTypeMeaning
payment.successfulPayment completed. Ship the goods.
payment.failedPayment was attempted and refused.
payment.expiredThe link expired without the customer paying.
payment.cancelledThe customer cancelled during checkout.
payment.revokedThe payment was revoked after the fact.

Signature header

HTTP header
SikinaPay-Signature: t=<unix-seconds>,v1=<hex-hmac-sha256>

How to verify

  1. Read the raw request body before parsing JSON — signature verification uses the exact bytes.
  2. Parse t (timestamp) and v1 (signature) from the header.
  3. Reject if now - t is outside your tolerance (5 minutes is a good default).
  4. Recompute signedContent = t + "." + raw_body, then expected = hex(HMAC_SHA256(webhook_secret, signedContent)).
  5. Compare expected with v1 using a constant-time comparison (hmac.compare_digest, crypto.timingSafeEqual, hash_equals).
  6. If the check passes, process the event. If not, respond 400 and 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 2xx as soon as you have persisted the event id and enqueued work. Fulfil asynchronously.
  • Any non-2xx response or a timeout causes retries for several hours. Design your handler to be idempotent.

Deduplication

  1. Check whether you have already processed this eventId. If yes, respond 200 and stop.
  2. 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

POST/api/v1/gateway/getPaymentStatus
const 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 | REVOKED

The responseMessage field reports the current status: PENDING, SUCCESSFUL, FAILED, EXPIRED, CANCELLED, REVOKED. Anything other than PENDING is final.

Tip

Nightly reconcile job

Every night, iterate over payments your side still considers non-terminal after a grace period (say, 30 minutes) and call 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 getPaymentStatus to reconcile.
Note

On network errors

If 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

POST/api/v1/gateway/generateSubscriptionPaymentLink

Same fields as generatePaymentLink, plus a required subscriptionConfiguration:

FieldTypeRequiredDescription
subscriptionConfiguration.intervalenumyesRecurrence unit: DAY, MONTH, or YEAR.
subscriptionConfiguration.intervalCycleinteger, 1–365yesNumber of interval units between charges. MONTH+1 = monthly; MONTH+3 = quarterly.
subscriptionConfiguration.totalCyclesinteger, ≥1optionalCap on total charges (including the first). Omit for open-ended.
subscriptionConfiguration.customerEmailstring, ≤256 charsoptionalCustomer email — used for receipts.
subscriptionConfiguration.customerPhonestring, ≤32 charsoptionalCustomer 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

01generateSubscriptionPaymentLink
02Customer pays first charge (hosted)
03subscription.activated → ACTIVE
04subscription.renewed × N
05subscription.expired (totalCycles reached)
06subscription.cancelled (merchant or customer)

Subscription events

eventTypeWhen it firesdata fields
subscription.activatedThe first charge succeeded and the subscription is now active.subscriptionId, clientSubscriptionId, status: ACTIVE, currentCycleCount, nextPaymentDate
subscription.renewedA subsequent cycle was charged successfully.Same as above, with the new currentCycleCount and nextPaymentDate.
subscription.payment_dueThe next charge is about to be attempted.status: PAYMENT_PENDING, currentCycleCount, nextPaymentDate
subscription.cancelledCancelled by the merchant, customer, or the gateway. No further charges.status: CANCELLED, cancellationReason
subscription.expiredThe subscription reached totalCycles and completed naturally.status: EXPIRED, currentCycleCount

Example subscription.renewed payload:

JSON
{
  "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

POST/api/v1/gateway/cancelSubscription
await 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 subscriptionId and clientSubscriptionId on subscription.activated and map them to the customer.
  • Grant access on subscription.activated; extend on subscription.renewed.
  • Revoke access on subscription.cancelled and subscription.expired.
  • Treat subscription.payment_due as informational — actual grant/revoke decisions come from the events that follow.
  • Handle duplicate deliveries via eventId, exactly as for payment events.
  • Point at the sandbox base URL https://sandbox.sikinapay.com and use your testKey.
  • 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

If something looks wrong that this guide does not cover, contact us with:

  • The paymentReferenceId and clientReferenceId involved.
  • The exact request and response (with the Authorization header redacted).
  • The currentDate and currentTime from the response, or your own request timestamp.