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.

Terminal
# the ledger: proves the key, the header and that you are talking to the right environment
curl -s https://api.harbour.africa/v1/transactions?limit=1 \
  -H "Authorization: Bearer hb_sk_test_your_key"

# a charge
curl -s -X POST https://api.harbour.africa/v1/charges \
  -H "Authorization: Bearer hb_sk_test_your_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: smoke-$(date +%s)" \
  -d '{"amount_kobo":1200000,"email":"funke@example.ng","reference":"smoke-1","return_url":"https://example.ng/thanks"}'

# and back again
curl -s https://api.harbour.africa/v1/charges/HB-88413 \
  -H "Authorization: Bearer hb_sk_test_your_key"

What the answers mean:

You getIt means
200 with "data": []Everything works. There are simply no test payments yet.
401 unauthorizedThe 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_providerThe key is fine. The business has no provider connected in this mode.
404 not_foundUsually 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
SDK language
  • PHP
  • Node.jsComing soon
  • PythonComing soon
  • JavaComing soon
  • GoComing soon
  • .NETComing soon
Any language can call the API today
tools/harbour-smoke.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
SDK language
  • PHP
  • Node.jsComing soon
  • PythonComing soon
  • JavaComing soon
  • GoComing soon
  • .NETComing soon
Any language can call the API today
<?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');
.env or from the environment
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
SDK language
  • PHP
  • Node.jsComing soon
  • PythonComing soon
  • JavaComing soon
  • GoComing soon
  • .NETComing soon
Any language can call the API today
<?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
SDK language
  • PHP
  • Node.jsComing soon
  • PythonComing soon
  • JavaComing soon
  • GoComing soon
  • .NETComing soon
Any language can call the API today
Your webhook tests
<?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
SDK language
  • PHP
  • Node.jsComing soon
  • PythonComing soon
  • JavaComing soon
  • GoComing soon
  • .NETComing soon
Any language can call the API today
<?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.completed credits 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.