After the payment

Webhooks

The only thing that should ever mark an order paid.

A webhook is Harbour telling your server that something happened, signed so you can prove it came from Harbour. It is the only thing that should ever mark an order paid. Everything else — the return URL, onSuccess, a customer's word — is a hint.

Add an endpoint

In the dashboard, Settings → Developers → Webhook endpoints: paste your URL, choose the mode, and copy the whsec_… secret shown once. Points worth knowing:

  • Each endpoint has its own secret and its own mode, so test deliveries never hit live code.
  • The URL must be reachable from the internet over HTTPS in live mode. Private, loopback and metadata addresses are refused, at save and again at delivery.
  • Test endpoint on that screen sends a real signed delivery, so you can prove the wiring before a customer does.

Verify every delivery

HTTP
Harbour-Signature: t=1757764935,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

v1 = hex(HMAC_SHA256(endpoint_secret, t + "." + raw_request_body))

handle() checks that HMAC in constant time, rejects a timestamp more than 300 seconds from now (so an old delivery cannot be replayed at you), and only then calls your closure with a typed Event:

PHP
SDK language
  • PHP
  • Node.jsComing soon
  • PythonComing soon
  • JavaComing soon
  • GoComing soon
  • .NETComing soon
Any language can call the API today
webhook.php
<?php

use Nordaxiz\HarbourConnect\Exception\SignatureVerificationException;
use Nordaxiz\HarbourConnect\Harbour;
use Nordaxiz\HarbourConnect\Webhook\Event;

try {
    Harbour::webhooks((string) getenv('HARBOUR_WEBHOOK_SECRET'))->handle(null, function (Event $event): void {
        // deliveries repeat: if you have seen this event id, stop here
        if (WebhookLog::seen($event->id)) {
            return;
        }
        WebhookLog::record($event->id, $event->type);

        match ($event->type) {
            Event::CHARGE_SUCCEEDED => fulfil($event->transaction()),
            Event::CHARGE_FAILED    => noteFailure($event->transaction()),
            Event::REFUND_COMPLETED => creditBack($event->refund()),
            Event::LINK_PAID        => markInvoicePaid($event->transaction()),
            default                 => null,
        };
    });
    http_response_code(200);
} catch (SignatureVerificationException $e) {
    http_response_code(400);           // not ours, or tampered with: do not retry it
}

Passing null reads the raw body from php://input and the headers from the server. In a framework, hand it the request object you already have:

PHP
SDK language
  • PHP
  • Node.jsComing soon
  • PythonComing soon
  • JavaComing soon
  • GoComing soon
  • .NETComing soon
Any language can call the API today
<?php

// any framework: hand the handler the request you already have
$handler = Harbour::webhooks((string) getenv('HARBOUR_WEBHOOK_SECRET'));

$handler->handle($psrServerRequest, $fn);                 // PSR-7
$handler->handle($symfonyOrLaravelRequest, $fn);          // Symfony / Laravel
$handler->handle(['body' => $raw, 'headers' => $heads], $fn);
$handler->handle($rawBody, $fn, ['Harbour-Signature' => $header]);

// or verify yourself and get the Event back
$event = $handler->constructEvent($rawBody, $signatureHeader);

The events

Eventdata isMeans
charge.succeededtransactionMoney was taken. Fulfil the order.
charge.failedtransactionThe attempt failed. failure_message says why.
link.paidtransactionA payment link was paid.
refund.requestedrefundSomeone asked for a refund; it is waiting for approval.
refund.completedrefundThe provider sent the money back.
refund.failedrefundThe provider refused or could not complete it.
refund.declinedrefundAn Owner declined the request.
settlement.landedsettlementA payout reached the bank account.
settlement.shortsettlementA payout landed short of the forecast.

They are constants on Event (Event::CHARGE_SUCCEEDED…). $event->transaction(), $event->refund() and $event->paymentLink() return typed objects when the payload matches, and $event->data always holds the raw array. Ignore event types you do not handle; more will be added.

What a delivery looks like

JSON charge.succeeded
{
  "id": "evt_01k5m0r3w9a2c8n7xq4v6t1b0d",
  "type": "charge.succeeded",
  "created_at": "2026-09-13T14:32:09Z",
  "mode": "live",
  "tenant_id": "ten_01m2e48qxzwqrwz0axj3r20jpv",
  "data": {
    "object": "transaction",
    "id": "txn_01k5m0r3w9a2c8n7xq4v6t1b0d",
    "reference": "HB-88413",
    "merchant_reference": "order-1043",
    "type": "charge",
    "status": "succeeded",
    "provider": "paystack",
    "provider_reference": "PSK_8h2k9d0f",
    "amount_kobo": 1200000,
    "fee_kobo": 18000,
    "net_kobo": 1182000,
    "refunded_kobo": 0,
    "currency": "NGN",
    "channel": "card",
    "customer": { "name": "Funke Adeyemi", "email": "funke@example.ng", "phone": null },
    "failure_message": null,
    "occurred_at": "2026-09-13T14:32:07Z",
    "settled_at": null,
    "settlement_id": null,
    "recovered": false,
    "metadata": { "order_id": "1043" },
    "mode": "live"
  }
}

The five rules

  1. Verify, always. An unverified POST is a stranger with your URL.
  2. De-duplicate on id. A retry, or a provider sending the same event twice, must not ship the goods twice. Store the event id with a unique index and return early.
  3. Check the money. Match merchant_reference and amount_kobo against your own record before fulfilling.
  4. Answer fast. 2xx within ten seconds. Queue anything slower: emails, PDFs, stock updates.
  5. Be honest with status codes. 2xx means "I have it". Anything else is retried at 1m, 5m, 30m, 2h, 6h and 12h for up to 24 hours. A 400 for a bad signature is correct; a 500 because your database blinked is also correct, and Harbour will come back.
PHP
SDK language
  • PHP
  • Node.jsComing soon
  • PythonComing soon
  • JavaComing soon
  • GoComing soon
  • .NETComing soon
Any language can call the API today
The fulfil step, in full
<?php

use Nordaxiz\HarbourConnect\Model\Transaction;

function fulfil(Transaction $transaction): void
{
    $order = Order::findByReference($transaction->merchantReference);

    // three checks before money means anything
    if ($order === null) { return; }
    if ($order->total_kobo !== $transaction->amountKobo) { alertFinance($order, $transaction); return; }
    if ($order->isPaid()) { return; }

    $order->markPaid($transaction->reference);
    Queue::push(new SendReceipt($order));       // slow work never happens in the request
}

Testing without a public URL

You do not need a tunnel to test your handler. Sign a payload the way Harbour does and post it at yourself:

PHP
SDK language
  • PHP
  • Node.jsComing soon
  • PythonComing soon
  • JavaComing soon
  • GoComing soon
  • .NETComing soon
Any language can call the API today
tools/send-webhook.php
<?php

use Nordaxiz\HarbourConnect\Webhook\Signature;

// build a delivery exactly as Harbour signs one, and post it at your own endpoint
$body = file_get_contents(__DIR__ . '/fixtures/charge.succeeded.json');
$header = Signature::header($body, 'whsec_your_endpoint_secret');

$ch = curl_init('http://localhost:8000/webhooks/harbour');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => $body,
    CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Harbour-Signature: ' . $header],
    CURLOPT_RETURNTRANSFER => true,
]);
echo curl_exec($ch);

That is real end-to-end coverage of your endpoint: the signature, the parsing, the de-duplication and the fulfilment. More on this, including what to assert, on Testing.