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
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
<?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
<?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
| Event | data is | Means |
|---|---|---|
charge.succeeded | transaction | Money was taken. Fulfil the order. |
charge.failed | transaction | The attempt failed. failure_message says why. |
link.paid | transaction | A payment link was paid. |
refund.requested | refund | Someone asked for a refund; it is waiting for approval. |
refund.completed | refund | The provider sent the money back. |
refund.failed | refund | The provider refused or could not complete it. |
refund.declined | refund | An Owner declined the request. |
settlement.landed | settlement | A payout reached the bank account. |
settlement.short | settlement | A 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
{
"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
- Verify, always. An unverified POST is a stranger with your URL.
- 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. - Check the money. Match
merchant_referenceandamount_koboagainst your own record before fulfilling. - Answer fast. 2xx within ten seconds. Queue anything slower: emails, PDFs, stock updates.
- 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
<?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
<?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.