HarakaPaye

API documentation

Everything you need to collect mobile money payments from your own website, app or backend, and to understand where the money goes.

Introduction

This gateway lets you charge customers through mobile money STK Push. Your server asks us to request a payment from a phone number. The customer receives a prompt, enters their mobile money PIN, and the mobile network tells us the result. We credit the payment to your wallet, notify your server, and you can withdraw the balance to any mobile money number.

The API is a small, predictable REST interface. Requests and responses are JSON, and you authenticate with a bearer token.

Base URLhttps://harakapaye.co.ke/api/v1
FormatJSON over HTTPS
CurrencyKES, whole shillings
Rate limit120 requests/minute per key

Keep the secret key on your server. Never put it in a website’s JavaScript, a mobile app or a public repository. Browsers and phones should call your own backend, which then calls this API.

Quickstart

You can take a test payment in about five minutes.

  1. Create an account

    Sign up, or sign in. New accounts may need approval from the gateway administrator before they can send payments.

  2. Generate an API key

    Open API keys, give the key a label (for example the site it belongs to) and press Generate key. You get three values in one place, each with a copy button, plus a ready-made .env block. The secret key is shown only once, so copy it immediately.

  3. Store the credentials on your server

    Put them in environment variables, not in your code:

    GATEWAY_API_URL=https://harakapaye.co.ke/api/v1
    GATEWAY_PUBLIC_KEY=pk_live_xxxxxxxxxxxxxxxxxxxx
    GATEWAY_SECRET_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
    GATEWAY_SIGNING_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  4. Send a payment request

    Call POST /stk/push with the customer’s phone and an amount. Use your own order id as reference.

    curl -X POST https://harakapaye.co.ke/api/v1/stk/push \
      -H "Authorization: Bearer $GATEWAY_SECRET_KEY" \
      -H "X-Public-Key: $GATEWAY_PUBLIC_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "phone": "0712345678",
        "amount": 1500,
        "reference": "ORDER-1042",
        "description": "Order 1042"
      }'
    <?php
    $ch = curl_init(getenv('GATEWAY_API_URL') . '/stk/push');
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer ' . getenv('GATEWAY_SECRET_KEY'),
            'Content-Type: application/json',
        ],
        CURLOPT_POSTFIELDS => json_encode([
            'phone'       => '0712345678',
            'amount'      => 1500,
            'reference'   => 'ORDER-1042',   // your own order id
            'description' => 'Order 1042',
        ]),
    ]);
    $res = json_decode(curl_exec($ch), true);
    
    if ($res['success']) {
        $gatewayRef = $res['data']['reference'];   // store this next to your order
    } else {
        error_log('STK push failed: ' . $res['error']);
    }
    const res = await fetch(`${process.env.GATEWAY_API_URL}/stk/push`, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.GATEWAY_SECRET_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        phone: '0712345678',
        amount: 1500,
        reference: 'ORDER-1042',      // your own order id
        description: 'Order 1042',
      }),
    });
    const body = await res.json();
    
    if (body.success) {
      console.log('Payment requested:', body.data.reference);
    } else {
      console.error('STK push failed:', body.error);
    }
    import os, requests
    
    res = requests.post(
        os.environ["GATEWAY_API_URL"] + "/stk/push",
        headers={"Authorization": "Bearer " + os.environ["GATEWAY_SECRET_KEY"]},
        json={
            "phone": "0712345678",
            "amount": 1500,
            "reference": "ORDER-1042",   # your own order id
            "description": "Order 1042",
        },
        timeout=30,
    )
    body = res.json()
    if body["success"]:
        print("Payment requested:", body["data"]["reference"])
    else:
        print("STK push failed:", body["error"])
  5. Learn the result

    Either wait for a webhook (recommended) or poll the payment. When the status becomes success, fulfil the order.

  6. Withdraw your money

    Go to Withdrawals, enter an amount and a mobile money number, and confirm with your password. See Withdrawals.

How a payment works

  1. Your server calls POST /stk/push with the phone, amount and your order reference.
  2. The gateway validates the request, records a pending payment under your account and asks the mobile network to send the prompt.
  3. The customer sees the payment prompt on their phone and enters their PIN (or cancels).
  4. The mobile network calls the gateway with the outcome. If the call is missed, the gateway asks the network directly.
  5. The gateway sets the final status. On success it credits your wallet with the full amount and debits the fee as a separate entry.
  6. Your server receives a signed webhook (or sees the new status when it polls) and fulfils the order.

A payment moves through these states only once. It starts as pending and ends as exactly one of success, failed, cancelled or expired.

Authentication

Each API key has three credentials. They are created together and are shown together on the API keys page.

CredentialLooks likePurposeSecret?
Public keypk_live_…Identifies the key. You may send it in the X-Public-Key header; if you do, it must match the secret key. It also appears in webhook headers so you can tell which key a webhook belongs to.No, safe to log
Secret keysk_live_…Authenticates API requests as a bearer token. Anyone holding it can create payments on your account.Yes. Shown once; only a hash is stored
Signing secretwhsec_…Verifies that a webhook really came from the gateway (HMAC-SHA256). Never used to call the API.Yes. Can be viewed again on the keys page

Sending the key

Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
X-Public-Key: pk_live_xxxxxxxxxxxxxxxxxxxx      (optional)
Content-Type: application/json

Good practice

  • One key per site or service. If one is compromised you revoke only that key.
  • Restrict by IP. Add your server’s addresses when you create the key. Requests from other addresses get 403.
  • Never expose the secret key in front-end code, logs, screenshots or version control.
  • Revoke on suspicion. Revoking is instant. Create a replacement key first to avoid downtime.

A missing, malformed or revoked key returns 401. A key that belongs to a suspended or unapproved account returns 403.

Conventions

TopicDetails
Request bodySend JSON with Content-Type: application/json. Form-encoded bodies are accepted for POST /stk/push too.
Response envelopeEvery response has success (true/false). Successful responses carry data. Failures carry error, a message written for humans.
AmountsWhole Kenya shillings as integers (1500 means KES 1,500). Decimals are not supported by STK Push. Allowed range: KES 1 to KES 250,000 per payment.
Phone numbersMobile numbers in any of these forms: 0712345678, 0112345678, 712345678, 254712345678, +254 712 345 678. We normalise them to 2547XXXXXXXX and return that form.
Your referencereference is your own order or invoice id (up to 64 characters). It makes requests idempotent and lets you look a payment up later by your own id.
TimestampsFormat YYYY-MM-DD HH:MM:SS in East Africa Time (Africa/Nairobi).
Rate limits120 requests per minute per key. Beyond that you get 429; wait a few seconds and retry.
IdempotencyIf you send a reference that already has a pending or success payment, we do not create another one. We return the existing payment with duplicate: true and HTTP 200. A failed, cancelled or expired attempt can be retried with the same reference.

Create a payment

POST /api/v1/stk/push

Starts an STK Push. The customer receives a payment prompt within a few seconds. The payment is created as pending; the final result arrives by webhook or polling.

Body parameters

FieldTypeRequiredDescription
phonestringYesCustomer’s mobile number in any supported format.
amountintegerYesWhole shillings, between 1 and 250,000.
referencestringRecommendedYour own order id (max 64 chars). Enables idempotency and lookups by your id.
descriptionstringNoShort note stored with the payment (max 60 chars). Some handsets show only the first 13 characters.
callback_urlstringNoWebhook URL for this payment only. Overrides the webhook URL saved in your settings.

Example request

curl -X POST https://harakapaye.co.ke/api/v1/stk/push \
  -H "Authorization: Bearer $GATEWAY_SECRET_KEY" \
  -H "X-Public-Key: $GATEWAY_PUBLIC_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phone": "0712345678",
    "amount": 1500,
    "reference": "ORDER-1042",
    "description": "Order 1042"
  }'
<?php
$ch = curl_init(getenv('GATEWAY_API_URL') . '/stk/push');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . getenv('GATEWAY_SECRET_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'phone'       => '0712345678',
        'amount'      => 1500,
        'reference'   => 'ORDER-1042',   // your own order id
        'description' => 'Order 1042',
    ]),
]);
$res = json_decode(curl_exec($ch), true);

if ($res['success']) {
    $gatewayRef = $res['data']['reference'];   // store this next to your order
} else {
    error_log('STK push failed: ' . $res['error']);
}
const res = await fetch(`${process.env.GATEWAY_API_URL}/stk/push`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.GATEWAY_SECRET_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    phone: '0712345678',
    amount: 1500,
    reference: 'ORDER-1042',      // your own order id
    description: 'Order 1042',
  }),
});
const body = await res.json();

if (body.success) {
  console.log('Payment requested:', body.data.reference);
} else {
  console.error('STK push failed:', body.error);
}
import os, requests

res = requests.post(
    os.environ["GATEWAY_API_URL"] + "/stk/push",
    headers={"Authorization": "Bearer " + os.environ["GATEWAY_SECRET_KEY"]},
    json={
        "phone": "0712345678",
        "amount": 1500,
        "reference": "ORDER-1042",   # your own order id
        "description": "Order 1042",
    },
    timeout=30,
)
body = res.json()
if body["success"]:
    print("Payment requested:", body["data"]["reference"])
else:
    print("STK push failed:", body["error"])

Response 201 Created

{
  "success": true,
  "duplicate": false,
  "message": "STK push sent. Customer should enter their mobile money PIN.",
  "data": {
    "reference": "KP3F9A1C0B7D",
    "external_reference": "ORDER-1042",
    "status": "pending",
    "amount": 1500,
    "fee": 0,
    "net": 0,
    "phone": "254712345678",
    "receipt_code": null,
    "message": null,
    "checkout_request_id": "ws_CO_04112026101530123712345678",
    "created_at": "2026-11-04 10:15:30",
    "completed_at": null
  }
}

Store data.reference next to your order. Fees and net are filled in when the payment succeeds.

Response when the reference already exists 200 OK

Same shape, with "duplicate": true and the existing payment in data. Do not ask the customer to pay again; check the status of that payment.

Possible errors

HTTPWhenExample message
401Missing, wrong or revoked keyInvalid or revoked API key
403IP not on the allowlist, or account inactiveRequest IP is not on this key’s allowlist
422Bad phone, amount outside limits, bad callback URLInvalid phone number. Use 07XXXXXXXX or 2547XXXXXXXX
429Rate limit or your daily collection limitDaily collection limit reached for this account
502The network rejected or could not process the pushThe payment record is kept as failed with the reason

Get a payment

GET /api/v1/payments/{reference}

Returns one payment. {reference} can be the gateway reference (KP3F9A1C0B7D) or your own reference (ORDER-1042). If a payment has been pending for more than about 20 seconds, this call also asks the network for the outcome, so polling recovers from a missed callback.

curl https://harakapaye.co.ke/api/v1/payments/ORDER-1042 \
  -H "Authorization: Bearer $GATEWAY_SECRET_KEY"
<?php
$ch = curl_init(getenv('GATEWAY_API_URL') . '/payments/ORDER-1042');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('GATEWAY_SECRET_KEY')],
]);
$res = json_decode(curl_exec($ch), true);
echo $res['data']['status'];   // pending | success | failed | cancelled | expired
const r = await fetch(`${process.env.GATEWAY_API_URL}/payments/ORDER-1042`, {
  headers: { Authorization: `Bearer ${process.env.GATEWAY_SECRET_KEY}` },
});
const { data } = await r.json();
console.log(data.status);   // pending | success | failed | cancelled | expired

Response for a paid order 200 OK

{
  "success": true,
  "data": {
    "reference": "KP3F9A1C0B7D",
    "external_reference": "ORDER-1042",
    "status": "success",
    "amount": 1500,
    "fee": 75,
    "net": 1425,
    "phone": "254712345678",
    "receipt_code": "QGH7X2K9LP",
    "message": "The service request is processed successfully.",
    "created_at": "2026-11-04 10:15:30",
    "completed_at": "2026-11-04 10:15:52"
  }
}

Polling tips: poll every 3 to 5 seconds for up to two minutes, then stop and treat the order as unpaid until a webhook says otherwise. Prefer webhooks in production; polling is a fallback and for pages that show a live “waiting for payment” screen. Unknown references return 404.

Get your balance

GET /api/v1/balance

Returns the amount available to withdraw now: collected payments, minus fees, minus withdrawals in progress or paid, plus or minus any adjustments by the administrator.

{
  "success": true,
  "data": { "balance": 48250, "currency": "KES" }
}

Payment statuses

StatusMeaningWhat to do
pendingThe prompt was sent and we are waiting for the customer.Wait for the webhook or poll. Do not fulfil the order yet.
successPaid. The wallet was credited and a receipt code is available in receipt_code.Fulfil the order.
failedThe payment did not complete: wrong PIN, insufficient funds, or an error. message says why.Show the reason and let the customer retry.
cancelledThe customer dismissed the prompt.Offer to resend the request.
expiredNo answer arrived in time (the phone was off, out of coverage, or the prompt was ignored).Offer to resend. A late success still credits the wallet and updates the status.

Fees and your wallet

The current collection fee is 5% per successful payment (your account may have a custom rate). Failed, cancelled and expired payments are free. Withdrawals cost KES 10 each, with a minimum of KES 100.

Worked example

A customer pays KES 1,500. We credit KES 1,500 to your wallet and debit a fee of KES 75, so your net is KES 1,425. Both entries appear in your ledger and on the payment: amount: 1500, fee: 75, net: 1425.

The customer’s money settles in the gateway’s collection account. Your wallet balance is the part of that money which belongs to you. Every credit and debit is recorded in an append-only ledger so the balance can always be explained.

Webhooks

A webhook is an HTTP POST from us to your server when a payment reaches a final status. It is the most reliable way to learn about payments because it works even if the customer closes their browser.

Set it up

  • Save a URL under Settings → Webhook (used for every payment), or pass callback_url on a single request.
  • The URL must be public and reachable over HTTP or HTTPS. Use HTTPS in production.
  • Respond with any 2xx status within 8 seconds. Do your slow work after responding.

Events

EventSent when
payment.successThe customer paid and your wallet was credited.
payment.failedThe payment failed (wrong PIN, insufficient funds, error).
payment.cancelledThe customer cancelled the prompt.
payment.expiredNo response arrived in time.

Headers we send

HeaderValue
X-Gateway-EventThe event name, for example payment.success.
X-Gateway-TimestampUnix time in seconds when the webhook was signed.
X-Gateway-Signaturesha256= followed by the hex HMAC. See Verify signatures.
X-Gateway-Key-IdThe public key of the API key that created the payment (absent for payments sent from the dashboard).
Content-Typeapplication/json

Payload

{
  "id": "evt_KP3F9A1C0B7D_success",
  "event": "payment.success",
  "data": {
    "reference": "KP3F9A1C0B7D",
    "external_reference": "ORDER-1042",
    "status": "success",
    "amount": 1500,
    "fee": 75,
    "net": 1425,
    "phone": "254712345678",
    "receipt_code": "QGH7X2K9LP",
    "message": "The service request is processed successfully.",
    "completed_at": "2026-11-04 10:15:52"
  }
}

Delivery and retries

  • Each webhook is attempted when the payment completes. If your server does not answer with a 2xx, it is retried automatically every minute, up to 5 attempts.
  • Each payment shows its webhook status (delivered or failed) on the Payments page, and you can press Resend webhook at any time.
  • The same event can arrive more than once. Use the id field to make your handler idempotent.
  • Webhooks may arrive out of order. Trust the status in the payload, and confirm with GET /payments/{reference} if you need certainty.

Verify signatures

Anyone can send a request to your webhook URL, so always verify the signature before trusting it. We sign the string timestamp + "." + raw_body with HMAC-SHA256 using your key’s signing secret.

  1. Read the raw request body without parsing or reformatting it.
  2. Take X-Gateway-Timestamp and build timestamp + "." + body.
  3. Compute the HMAC-SHA256 hex digest with your signing secret and prefix it with sha256=.
  4. Compare it to X-Gateway-Signature with a constant-time comparison.
  5. Reject the request if the timestamp is more than 5 minutes old (replay protection).
<?php
$secret = getenv('GATEWAY_SIGNING_SECRET');
$body   = file_get_contents('php://input');                  // the RAW body, exactly as received
$ts     = $_SERVER['HTTP_X_GATEWAY_TIMESTAMP'] ?? '';
$sig    = $_SERVER['HTTP_X_GATEWAY_SIGNATURE'] ?? '';

$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $body, $secret);

if (!hash_equals($expected, $sig) || abs(time() - (int) $ts) > 300) {
    http_response_code(401);                                  // reject: forged or replayed
    exit;
}

$event = json_decode($body, true);
if ($event['event'] === 'payment.success') {
    // Look up the order by $event['data']['external_reference'], mark it paid.
    // Use $event['id'] to ignore duplicates if the same event arrives twice.
}
http_response_code(200);                                      // any 2xx = delivered
const crypto = require('crypto');
const express = require('express');
const app = express();

// Keep the RAW body: signatures are computed over the exact bytes we sent
app.post('/payment-webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const ts = req.header('X-Gateway-Timestamp') || '';
  const sig = req.header('X-Gateway-Signature') || '';
  const expected = 'sha256=' + crypto
    .createHmac('sha256', process.env.GATEWAY_SIGNING_SECRET)
    .update(ts + '.' + req.body.toString('utf8'))
    .digest('hex');

  const ok = sig.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  if (!ok || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString('utf8'));
  if (event.event === 'payment.success') {
    // mark event.data.external_reference as paid (ignore repeats of event.id)
  }
  res.sendStatus(200);
});
import hmac, hashlib, os, time
from flask import Flask, request, abort

app = Flask(__name__)

@app.post("/payment-webhook")
def webhook():
    raw = request.get_data()                       # RAW body bytes
    ts = request.headers.get("X-Gateway-Timestamp", "")
    sig = request.headers.get("X-Gateway-Signature", "")
    expected = "sha256=" + hmac.new(
        os.environ["GATEWAY_SIGNING_SECRET"].encode(),
        ts.encode() + b"." + raw,
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(expected, sig) or abs(time.time() - int(ts or 0)) > 300:
        abort(401)

    event = request.get_json()
    if event["event"] == "payment.success":
        pass  # mark event["data"]["external_reference"] as paid
    return "", 200

Which secret? Payments created with an API key are signed with that key’s signing secret. Payments sent from the dashboard form are signed with the account secret shown on Settings.

Withdrawals

Withdrawals are made from your dashboard, not through the API, so that moving money always involves a person.

  1. Open Withdrawals, enter an amount and any mobile money number, and confirm with your password.
  2. The amount plus the withdrawal fee leaves your balance immediately, so it cannot be spent twice. The request shows as pending.
  3. The gateway administrator reviews it and may mark it approved.
  4. The administrator sends the money to the number and records the receipt. The request becomes paid and the receipt code appears in your history.
  5. If the request is rejected, the amount and fee are returned to your wallet and a note explains why.

Verification (KYC) and support

To keep the platform safe, merchants verify their identity by uploading three photos: the front and back of an ID (National ID, passport or driving licence) and a selfie holding that ID, on the Verification page. An administrator reviews the documents and approves or rejects them.

WhenWhat happens
Your successful deposits exceed KES 1,000Verification becomes mandatory. You see a notice on every page and withdrawals are paused until your ID is approved.
An administrator asks you to verify (including random compliance checks)You are asked to upload your ID. Withdrawals pause until it is approved.
You do not verifyAn administrator can suspend the account. A suspended account cannot create payments through the API (403), but you can still sign in to upload your ID and to use Support.
Your ID is approvedThe account is reactivated automatically if it was suspended for KYC, and withdrawals unlock.
Your ID is rejectedThe reason is shown on the Verification page. Upload clearer documents and submit again.

Need help? Open the Support page to start a conversation with the gateway team. Replies appear in the same thread.

Testing

Simulation mode

When the administrator runs the gateway in Simulation mode, no real prompts are sent. Payments stay pending until the administrator marks them paid or failed, which exercises your whole flow: API call, status polling, webhook and wallet credit.

Sandbox

In Sandbox mode the gateway talks to the network’s test environment using a test shortcode. Use the test phone numbers that come with your test credentials; real handsets do not receive prompts.

Live, with small amounts

Before launch, make a few real payments of KES 1 to 10 from your own phone. Check that the status turns success, the wallet balance rises, your webhook arrives and verifies, and a withdrawal completes.

Suggested test checklist

  • Successful payment, wrong PIN and cancelled prompt.
  • Retrying a payment with the same reference (should be idempotent).
  • Webhook delivered while your server is down, then resent.
  • A webhook with a tampered body is rejected by your verifier.

Errors

Errors use standard HTTP status codes and this body:

{
  "success": false,
  "error": "Invalid phone number. Use 07XXXXXXXX or 2547XXXXXXXX"
}
HTTPMeaningTypical fix
200/201Success (201 when a new payment was created).—
401Missing, malformed or revoked key, or X-Public-Key mismatch.Check the Authorization: Bearer sk_… header and that the key is active.
403IP not allowed or account not active.Add your server IP to the key, or ask the administrator to approve the account.
404Unknown endpoint or payment.Check the path and reference.
405Wrong HTTP method.Use POST for /stk/push.
422Validation failed.Fix the phone, amount or callback URL named in the message.
429Rate limit or daily collection limit reached.Back off and retry, or ask for a higher limit.
502The network could not send the prompt.Retry shortly. The payment record is kept as failed.
500Unexpected server error.Retry with the same reference; it is safe.

Payment result codes

When a payment does not succeed, message carries the network’s explanation. These are the common outcomes:

CodeMeaningStatus you will see
0Successsuccess
1Customer’s wallet balance is insufficientfailed
2001Wrong PIN enteredfailed
1001Another transaction is already in progress for that customerfailed
1019The transaction expiredfailed
1025The prompt could not be sent to the phonefailed
1032Customer cancelled the promptcancelled
1037Timed out waiting for the customer’s phoneexpired

More codes may be added over time. Always treat any status other than success as unpaid.

Go-live checklist

  • Secret key and signing secret stored in server environment variables, not in code or Git.
  • An IP allowlist set on the live key.
  • Webhook URL is HTTPS, verifies the signature and replies 2xx quickly.
  • Webhook handler is idempotent (uses id or your order state).
  • Orders are fulfilled only on success, never on a browser redirect.
  • Your site shows a clear “check your phone” screen and handles failed, cancelled and expired outcomes.
  • You made at least one small live payment and one withdrawal end to end.
  • You know who to contact for support (ask the gateway administrator).

Troubleshooting

I get 401 even though my key is right.
Make sure the header is exactly Authorization: Bearer sk_live_… with one space and no quotes. Some servers strip the header; if you host this gateway yourself, check the .htaccess rule that passes it through. A revoked key also returns 401.
The customer never gets a prompt.
Check the number belongs to a supported mobile network, that the phone has coverage, and that the status is not failed with a reason. If you run the gateway yourself, verify the mobile money API credentials with Test API connection in Gateway settings.
A payment is pending for a long time.
The customer may not have answered. After a few minutes it becomes expired. If they did pay, the gateway will still credit it when the network reports success.
My webhook is not arriving.
Confirm the URL is public (not localhost), responds in under 8 seconds with a 2xx, and is not blocked by a firewall. Use Resend webhook on the Payments page and check the status shown there.
Signature verification always fails.
You must hash the raw body, not a re-serialised JSON object, and use the signing secret of the key that created the payment. Build the string as timestamp + "." + body and compare against the full header value including sha256=.
I sent the same reference twice and got duplicate: true.
That is intentional: it protects the customer from a double charge. Use a new reference for a new order, or check the status of the existing payment.