Reference
Testing your integration
Test mode end to end, local development, and testing without the internet.
There are three things worth testing, and they are separate: that you can reach Harbour, that a real payment works end to end in test mode, and that your webhook handler does the right thing when it is called. The first two need a provider; the third needs nothing but your own code.
What test mode does
- A
hb_sk_test_key only ever reaches the business's test provider keys, so nothing you do can move real money. - Every list, the dashboard feed and the ledger are filtered by the key's mode: a test key sees test payments only, which is why a test key against a live business looks like an empty account.
- Cards come from the provider, not from Harbour: use Paystack's test cards or Flutterwave's or OPay's.
- Webhook endpoints have a mode of their own, so test deliveries can go to a different URL from live ones.
Checking the API by hand
Start here, before any code. It takes a minute and rules out the two things that break most first integrations: the wrong key and a proxy eating your header.
curl -s https://api.harbour.africa/v1/transactions?limit=1 \
curl -s -X POST https://api.harbour.africa/v1/charges \
curl -s https://api.harbour.africa/v1/charges/HB-88413 \
What the answers mean:
| You get | It means |
|---|---|
200 with "data": [] | Everything works. There are simply no test payments yet. |
401 unauthorized | The key is wrong, revoked, or from the other environment. If the message is "Send an Authorization: Bearer header" and you did send one, something between you and Harbour is stripping it — a proxy, or an Apache that is not passing it to PHP. |
422 no_provider | The key is fine. The business has no provider connected in this mode. |
404 not_found | Usually a live reference being looked up with a test key, or the other way round. |
Checking the SDK
A twenty-line script that proves the key, the ledger, a charge and the error mapping, all over real HTTP:
PHP
<?php
require __DIR__ . '/vendor/autoload.php';
use Nordaxiz\HarbourConnect\Exception\HarbourException;
use Nordaxiz\HarbourConnect\Harbour;
$harbour = Harbour::tenant(getenv('HARBOUR_SECRET_KEY'), getenv('HARBOUR_API_BASE') ?: null);
printf("mode %s\n", $harbour->mode()); // "test"
$page = $harbour->transactions()->list(['limit' => 3]);
printf("ledger: %d row(s) for %s\n", count($page->data), $page->tenantId);
try {
$checkout = $harbour->charge()->amount(12_000_00)->customer('funke@example.ng')->create();
printf("charge: %s %s\n", $checkout->reference, $checkout->checkoutUrl);
printf("verify: %s\n", $harbour->verify($checkout->reference)->status);
} catch (HarbourException $e) {
printf("charge refused: %s (%d) %s\n", $e->getErrorCode(), $e->getHttpStatus(), $e->getMessage());
}
Run it against test keys on every environment you deploy to, including production with a test key. It is the fastest way to tell a broken deploy from a broken integration.
Against a local Harbour
If you are running Harbour itself locally, point the SDK at it. Plain HTTP is allowed for
localhost, *.localhost and 127.0.0.1; every other host must be HTTPS.
PHP
<?php
// plain HTTP is allowed for localhost, *.localhost and 127.0.0.1 only
Harbour::configure('hb_sk_test_…', 'http://localhost/harbour-api');
// or, with the host-based local setup
Harbour::configure('hb_sk_test_…', 'http://api.harbour.localhost:8080');
HARBOUR_SECRET_KEY=hb_sk_test_your_key
HARBOUR_WEBHOOK_SECRET=whsec_your_endpoint_secret
HARBOUR_API_BASE=http://localhost/harbour-api
A trailing /v1 on the base is accepted and ignored. Staging is
https://api.staging.harbour.africa.
Testing your webhook endpoint
Two ways, and you want both.
A real delivery from Harbour
Settings → Developers → Webhook endpoints → Test sends a genuine signed delivery to your URL and shows you the status code that came back. That proves DNS, TLS, your firewall and your route in one click. It needs a URL Harbour can reach; a tunnel (ngrok, Cloudflare Tunnel) is fine for development.
A signed delivery you make yourself
No tunnel, no internet, and it can live in your test suite. Signature::header() signs a body exactly as
Harbour does:
PHP
<?php
use Nordaxiz\HarbourConnect\Webhook\Signature;
$body = json_encode([
'id' => 'evt_test_' . bin2hex(random_bytes(6)),
'type' => 'charge.succeeded',
'created_at' => gmdate('Y-m-d\TH:i:s\Z'),
'mode' => 'test',
'tenant_id' => 'ten_test',
'data' => [
'object' => 'transaction',
'id' => 'txn_test',
'reference' => 'HB-88413',
'merchant_reference' => 'order-1043', // an order that exists in your database
'status' => 'succeeded',
'amount_kobo' => 12_000_00, // exactly what that order costs
'currency' => 'NGN',
'channel' => 'card',
'provider' => 'paystack',
'mode' => 'test',
],
], JSON_THROW_ON_ERROR);
$header = Signature::header($body, getenv('HARBOUR_WEBHOOK_SECRET'));
Then POST that body with Harbour-Signature: {$header} at your own endpoint. The
webhooks page has a full payload to copy.
In your own test suite
Three cases cover almost everything that goes wrong in production:
PHP
<?php
use Nordaxiz\HarbourConnect\Webhook\Signature;
public function test_a_signed_delivery_marks_the_order_paid(): void
{
$order = Order::factory()->create(['reference' => 'order-1043', 'total_kobo' => 12_000_00]);
[$body, $header] = $this->harbourEvent('charge.succeeded', $order);
$this->call('POST', '/webhooks/harbour', [], [], [], ['HTTP_HARBOUR_SIGNATURE' => $header], $body)
->assertOk();
$this->assertTrue($order->fresh()->isPaid());
}
public function test_the_same_delivery_twice_ships_once(): void
{
// …post the identical body and header again; the second must not fulfil a second time
}
public function test_a_tampered_body_is_refused(): void
{
[$body, $header] = $this->harbourEvent('charge.succeeded', $order);
$this->call('POST', '/webhooks/harbour', [], [], [], ['HTTP_HARBOUR_SIGNATURE' => $header], $body . ' ')
->assertStatus(400);
}
For the outgoing side, inject a fake transport instead of letting tests talk to the network:
PHP
<?php
use Nordaxiz\HarbourConnect\HarbourClient;
use Nordaxiz\HarbourConnect\Http\Request;
use Nordaxiz\HarbourConnect\Http\Response;
use Nordaxiz\HarbourConnect\Http\Transport;
final class FakeTransport implements Transport
{
/** @var list<Request> */
public array $requests = [];
/** @param list<Response> $answers */
public function __construct(private array $answers) {}
public function send(Request $request): Response
{
$this->requests[] = $request;
return array_shift($this->answers) ?? new Response(500, [], '{"error":{"code":"server_error"}}');
}
}
$harbour = new HarbourClient('hb_sk_test_x', null, [
'transport' => new FakeTransport([
new Response(201, [], json_encode(['reference' => 'HB-88413', 'checkout_url' => 'https://pay.harbour.africa/c/ct_x', 'status' => 'open'])),
]),
]);
$checkout = $harbour->charge()->amount(12_000_00)->customer('funke@example.ng')->create();
// then assert on $transport->requests: the path, the body, the Idempotency-Key header
The SDK's own suite works this way, so it runs offline: composer test in the package, or
php tests/run.php webhook for one area.
Before you call it done
- A test payment succeeds and your order is marked paid by the webhook, with the return URL disabled.
- The same delivery twice fulfils once.
- A tampered body is refused with 400.
- A failed payment does not mark anything paid, and the failure message reaches your logs.
- An amount mismatch raises an alert instead of shipping.
- A refund request appears in the dashboard queue, and
refund.completedcredits the order back. - Your endpoint answers in well under ten seconds with everything slow queued.
- Secret keys are absent from the repository, the browser bundle and the logs.
Then Going live.