Webhooks

StarPay POSTs a signed JSON payload to your endpoint URLs when events occur. Register endpoints (each gets its own signing secret, shown once) and subscribe to the events you care about.

Payload

{
  "id": "evt_123",
  "event": "charge.success",
  "data": { "id": "ch_abc", "amount": 250000, "currency": "GHS", "status": "success" }
}

Events include charge.success, charge.failed, transfer.success, transfer.failed, and transfer.reversed.

Verify the signature

Each request carries X-Starpay-Timestamp and X-Starpay-Signature = HMAC_SHA256(endpoint_secret, "{timestamp}.{sha256(raw_body)}"). Compute the hash over the raw body, before parsing, and reject timestamps older than 300 seconds.

Python

import hashlib, hmac, time

def verify(secret, timestamp, raw_body: bytes, signature, tolerance=300) -> bool:
    if abs(time.time() - int(timestamp)) > tolerance:
        return False
    body_hash = hashlib.sha256(raw_body).hexdigest()
    expected = hmac.new(secret.encode(), f"{timestamp}.{body_hash}".encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Node.js

const crypto = require("crypto");

function verify(secret, timestamp, rawBody, signature, tolerance = 300) {
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > tolerance) return false;
  const bodyHash = crypto.createHash("sha256").update(rawBody).digest("hex");
  const expected = crypto.createHmac("sha256", secret)
    .update(`${timestamp}.${bodyHash}`).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}

PHP

function starpay_verify($secret, $timestamp, $rawBody, $signature, $tolerance = 300): bool {
    if (abs(time() - (int) $timestamp) > $tolerance) return false;
    $bodyHash = hash('sha256', $rawBody);
    $expected = hash_hmac('sha256', "{$timestamp}.{$bodyHash}", $secret);
    return hash_equals($expected, $signature);
}

Retries

Respond 2xx to acknowledge, within 10 seconds: acknowledge first and do any slow work afterwards. Failures (and no answer in time) are retried up to 5 times with backoff — 1m, 5m, 30m, 2h, 12h — then dead-lettered and surfaced in your dashboard, where you can inspect and redeliver them.

Deduplicate on the event id

Delivery is at-least-once: the same event can reach you more than once, when an attempt your server did receive is retried (a 2xx that was slow, or lost on the way back) or when you redeliver it from the dashboard. Every attempt and every redelivery of one event carries the same id (evt_…). Record the ids you have processed, and answer a repeat with 2xx without acting on it again, above all for charge.success, order.paid and any other event you fulfil on. Each of your endpoints receives its own copy of an event, with its own id.

Events are not guaranteed to arrive in order: a retried event can arrive after later ones. Read the state from the resource in data (its status), not from the order the events came in.