Search by

reeds-hq / reeds-sdk

reeds-hq

The official PHP library for the Reeds APIs: one client for every Reeds service. Shipping: quotes, deliveries, labels, wallet, invoices and webhooks.

v0.2.0 2026-11-11 15:05 UTC

This package is auto-updated.

Last update: 2026-11-11 15:06:13 UTC


README

Reeds

Reeds PHP library

The official PHP library for the Reeds APIs: one client for every Reeds service.

Packagist version CI PHP version MIT license

Developer docs · API reference · API changelog · Library changelog

$reeds = new Reeds\Reeds(); // reads REEDS_CLIENT_ID and REEDS_CLIENT_SECRET

$quote = $reeds->shipping->quotes->create(['pickup' => $pickup, 'dropoff' => $dropoff, 'package' => ['description' => 'Sneakers', 'quantity' => 1, 'weight_kg' => 1.5]]);
if ($quote['available']) {
    $delivery = $reeds->shipping->deliveries->create(['quote_id' => $quote['id'], 'reference' => 'ORDER-10045']);
    echo $delivery['tracking_code']; // HQ3W5F2
}

Contents

Services

Reeds offers its services as separate APIs. The library gives each one a property on the same client, so one install and one set of credentials cover them all:

Service On the client Status
Shipping: quotes, address search, deliveries, labels, the wallet, invoices, the sandbox $reeds->shipping Available

Webhooks are shared: one endpoint receives every service's events, all verified by $reeds->webhooks. A new service arrives in a minor version of this library, so upgrading never breaks the services you already use.

Why use the library

Authentication handled It exchanges your client id and secret for an access token, caches it, and refreshes it before it expires.
Safe retries Every POST carries an Idempotency-Key, and a retry reuses it, so a retry never books or charges twice.
Resilient It retries rate limits, overload and dropped connections, honouring Retry-After.
Typed It ships PHPStan and Psalm array shapes for every object, generated from the API's OpenAPI spec.
Pagination foreach walks every page of a list.
Clear errors One exception class per kind of failure, with the API's errorCode, the invalid fields, a docUrl and the requestId.
Webhooks verified constructEvent checks the signature and returns the decoded event.
Laravel ready A service provider and a facade, discovered automatically.

Install

composer require reeds-hq/reeds-sdk

It needs PHP 8.1 or later and Guzzle 7 or 8, which Composer installs for you. Laravel already ships Guzzle.

Credentials and modes

Get your client id and secret from Dashboard → Developers. Each mode has its own pair:

Mode Client id Client secret What happens
Test rci_test_... rcs_test_... Nothing real moves and no real money is spent. Use it to build and test.
Live rci_live_... rcs_live_... Real pickups, real charges. Available once Reeds approves your business.
$reeds = new Reeds\Reeds([
    'client_id' => getenv('REEDS_CLIENT_ID'),
    'client_secret' => getenv('REEDS_CLIENT_SECRET'),
]);

$reeds->mode;      // "test" or "live", from the client id
$reeds->scopes();  // the services your credentials can use, for example ['shipping']

client_id and client_secret default to the REEDS_CLIENT_ID and REEDS_CLIENT_SECRET environment variables. Keep the secret on your server: never put it in a browser or a mobile app.

Shipping

The Shipping API books pickups and deliveries: within a state, and between states along the routes Reeds runs.

Quickstart

<?php

require 'vendor/autoload.php';

$reeds = new Reeds\Reeds();

// 1. Price it.
$quote = $reeds->shipping->quotes->create([
    'pickup' => ['name' => 'Tola Fabrics', 'phone' => '08051234567', 'address' => '23 Allen Avenue', 'area' => 'Ikeja', 'lga' => 'Ikeja', 'state' => 'Lagos'],
    'dropoff' => ['name' => 'Chinedu Eze', 'phone' => '08098765432', 'address' => '14 Admiralty Way', 'area' => 'Lekki Phase 1', 'lga' => 'Eti-Osa', 'state' => 'Lagos'],
    'package' => ['description' => '2 pairs of sneakers', 'quantity' => 1, 'weight_kg' => 1.5],
]);

if (!$quote['available']) {
    // Not an error: Reeds doesn't serve this route. Tell your customer why.
    exit($quote['reason']['message']);
}
echo $quote['fee']['total']; // kobo

// 2. Book it. The wallet is charged and the delivery gets its tracking code.
$delivery = $reeds->shipping->deliveries->create(['quote_id' => $quote['id'], 'reference' => 'ORDER-10045']);

// 3. Follow it with webhooks (see below), or read it.
$latest = $reeds->shipping->deliveries->get($delivery['id']);
echo $latest['status'], ' ', $latest['tracking_url'];

Objects are plain arrays with the API's own field names (quote_id, tracking_code), so your code reads like the API reference, and fields Reeds adds later are simply there. Amounts are integers in kobo: 280000 is ₦2,800.

Coverage and quotes

$coverage = $reeds->shipping->coverage->get(); // the states, zones and inter-state routes Reeds serves

A quote holds its price for 24 hours. A route Reeds doesn't serve comes back as 'available' => false with a reason (ADDRESS_NOT_SERVICEABLE, INTERSTATE_NOT_AVAILABLE, ...), never as an exception. Branch on $quote['reason']['code'], not the message.

Address search

Complete an address as your customer types, or read one from a map pin, and give the quote a place_id with the lga and state it needs. The addresses come from Google and are in Nigeria. Call these from your server, as every call with your credentials is.

// One session token per address being typed: the same on every call for that address, and on the
// details call that ends it, so the whole search counts once. Start a new one for the next address.
$session = bin2hex(random_bytes(16));

$found = $reeds->shipping->addresses->autocomplete(['query' => '23 Allen Avenue', 'session_token' => $session]);
foreach ($found['suggestions'] as $suggestion) {
    echo $suggestion['main_text'], ', ', $suggestion['secondary_text'], PHP_EOL; // at most 6, best first
}

// The suggestion your customer picked, with the same token:
$place = $reeds->shipping->addresses->details(['place_id' => $found['suggestions'][0]['place_id'], 'session_token' => $session]);
if ($place['lga'] === null || !$place['precise']) {
    // A quote needs the LGA, and an imprecise place is a whole area or road (its point is only the middle).
    exit('Ask your customer for the street and its LGA.');
}

$pickup = [
    'name' => 'Tola Fabrics',
    'phone' => '08051234567',
    'address' => $place['formatted_address'],
    'lga' => $place['lga'],
    'state' => $place['state'],
    'place_id' => $place['place_id'],
];
if ($place['area'] !== null) {
    $pickup['area'] = $place['area'];
}

$quote = $reeds->shipping->quotes->create([
    'pickup' => $pickup,
    'dropoff' => ['name' => 'Chinedu Eze', 'phone' => '08098765432', 'address' => '14 Admiralty Way', 'area' => 'Lekki Phase 1', 'lga' => 'Eti-Osa', 'state' => 'Lagos'],
    'package' => ['description' => '2 pairs of sneakers', 'quantity' => 1, 'weight_kg' => 1.5],
]);

// A map pin: the nearest address, with its own coordinates. Each pin is one search.
$pin = $reeds->shipping->addresses->reverse(['latitude' => 6.6018, 'longitude' => 3.3515]);

To deliver to the pin itself, send your pin's latitude and longitude in the quote with the address as the text; to deliver to the address found, send its place_id.

Address search is free. In Test mode an account has 50 searches to start (Reeds can raise it); in Live it is unlimited unless Reeds sets a monthly limit for the account. Past the allowance or the limit, a search throws a PermissionDeniedException with ADDRESS_SEARCH_LIMIT_REACHED, and ADDRESS_SEARCH_DISABLED when Reeds has switched it off for the account. The addresses are Google's: show the Google logo or "Powered by Google" next to suggestions that aren't on a Google map, and show pins on a Google map. See Address search.

Deliveries

Method What it does
deliveries->create(['quote_id' => ..., 'reference' => ..., 'ready' => ...]) Books the delivery a quote priced, and charges it
deliveries->list(['status' => ..., 'reference' => ..., 'ids' => [...], 'created_from' => ..., 'created_to' => ..., 'limit' => ...]) Deliveries, newest first (see pagination)
deliveries->get($id) One delivery
deliveries->update($id, ['reference' => ..., 'pickup_instructions' => ..., 'dropoff_instructions' => ..., 'metadata' => [...]]) Changes what can change
deliveries->markReady($id) The parcel is packed: the pickup is scheduled from now
deliveries->cancel($id, ['reason' => ...]) Cancels and refunds in full, until the rider sets off
deliveries->book($id) Books a priced delivery your team made in the dashboard
deliveries->label($id) The shipping label, as a PDF

Not packed yet? Book with 'ready' => false. The delivery is charged and gets its tracking code, then waits with a MARK_READY action:

$delivery = $reeds->shipping->deliveries->create(['quote_id' => $quote['id'], 'ready' => false]);
// ...when the parcel is packed:
$reeds->shipping->deliveries->markReady($delivery['id']); // now "scheduled", with its pickup and delivery times

A booked delivery that isn't marked ready within 7 days is cancelled and refunded in full.

Delivery statuses

Status Meaning
requires_action Waiting on you: required_actions says what (for example MARK_READY)
scheduled Booked and charged, waiting for its pickup time and a rider
rider_assigned A rider has the pickup
picked_up Collected from the pickup address
at_hub At a Reeds or partner hub
in_transit Moving between hubs on an inter-state delivery
out_for_delivery With the rider, heading to the receiver
delivered Delivered. Final.
failed_attempt The rider couldn't deliver. Reeds retries or contacts you.
on_hold Paused by Reeds. hold_reason says why.
cancelled Cancelled. Final.

Reeds can add statuses: give every match or switch on status a default arm.

Labels

$label = $reeds->shipping->deliveries->label($delivery['id']);
file_put_contents($label->filename ?? 'label.pdf', $label->data); // $label->contentType is "application/pdf"

Wallet and invoices

$wallet = $reeds->shipping->wallet->get();
echo $wallet['balance']; // kobo

// Live mode: start a Paystack top-up (₦50,000) and send the payer to the URL.
// wallet.funded carries the same reference when the money lands.
$topUp = $reeds->shipping->wallet->fund(['amount' => 5_000_000]);
header('Location: ' . $topUp['payment_url']);

foreach ($reeds->shipping->wallet->listTransactions() as $entry) {
    echo $entry['type'], ' ', $entry['amount'], ' ', $entry['description'], PHP_EOL;
}

// Accounts billed monthly:
$invoices = $reeds->shipping->invoices->list(['status' => 'issued']);
$pdf = $reeds->shipping->invoices->pdf($invoices->data[0]['id']);

Test mode and the sandbox

With test credentials, build the whole flow without real money or riders:

$reeds->shipping->sandbox->fundWallet(['amount' => 1_000_000]);                      // ₦10,000 in the test wallet, at once
$reeds->shipping->sandbox->advanceDelivery($delivery['id']);                          // the next status, with its webhook
$reeds->shipping->sandbox->advanceDelivery($delivery['id'], ['status' => 'delivered']); // straight to a status

Sandbox calls are refused before anything is sent when the client has live credentials.

Webhooks

Reeds posts events to your endpoint as things happen. Verify each one before you trust it: pass the raw request body, because a body that was decoded and encoded again never matches the signature. The signing secret (whsec_test_... or whsec_live_...) is next to your endpoint in Dashboard → Developers. It isn't your client secret.

Plain PHP

use Reeds\Exception\WebhookSignatureException;

$webhooks = new Reeds\Webhooks(); // no credentials needed to verify

try {
    $event = $webhooks->constructEvent(
        file_get_contents('php://input'),
        $_SERVER['HTTP_REEDS_SIGNATURE'] ?? null,
        getenv('REEDS_WEBHOOK_SECRET'),
    );
} catch (WebhookSignatureException $e) {
    http_response_code(400);
    exit($e->getMessage());
}

switch ($event['type']) {
    case 'delivery.status_changed':
        $delivery = $event['data']['delivery'];
        error_log("{$delivery['id']}: {$event['data']['previous_status']} -> {$delivery['status']}");
        break;
    case 'wallet.low_balance':
        error_log('Top up: the wallet is at ' . $event['data']['balance']);
        break;
    default:
        break; // Reeds adds event types over time: ignore the ones you don't use
}
http_response_code(200);

Symfony: $webhooks->constructEvent($request->getContent(), $request->headers->get('Reeds-Signature'), $secret). For Laravel, see below.

Event When
delivery.created A delivery was created: booked from a quote, or made in the dashboard
delivery.requires_action An action was added to required_actions
delivery.booked The delivery was priced and charged (or added to the month's invoice)
delivery.status_changed Any status change
delivery.schedule_changed The pickup day or delivery time moved
delivery.expiring A delivery will be cancelled soon if nothing happens
delivery.cancelled The delivery was cancelled
wallet.funded A top-up landed and the wallet was credited
wallet.low_balance The balance fell to or below your low-balance threshold
invoice.issued A monthly invoice was issued
invoice.paid An invoice was paid in full
invoice.overdue An invoice passed its due date unpaid

Good to know:

  • Duplicates: an event can arrive more than once. Skip $event['id']s you've already processed.
  • Order: events can arrive out of order. Compare $event['data']['delivery']['updated_at'].
  • Rotating a secret: pass both while you rotate, as constructEvent($body, $header, [$newSecret, $oldSecret]).
  • Testing your receiver: $reeds->webhooks->sendTest(['type' => 'delivery.booked']) sends a signed test event to your endpoint.

Laravel

The package's service provider and Reeds facade are discovered automatically. Add your credentials to config/services.php:

'reeds' => [
    'client_id' => env('REEDS_CLIENT_ID'),
    'client_secret' => env('REEDS_CLIENT_SECRET'),
    'webhook_secret' => env('REEDS_WEBHOOK_SECRET'),
],

Then inject the client, or use the facade:

use Reeds\Reeds;

final class ShipOrder
{
    public function __construct(private Reeds $reeds)
    {
    }

    public function handle(string $quoteId, string $orderNumber): array
    {
        return $this->reeds->shipping->deliveries->create(
            ['quote_id' => $quoteId, 'reference' => $orderNumber],
            ['idempotency_key' => "order-{$orderNumber}-delivery"],
        );
    }
}

// Or, anywhere:
$delivery = \Reeds\Laravel\Facades\Reeds::shipping()->deliveries->get('dlv_9Kd3Qx7M');

A webhook route, in routes/api.php (no CSRF there; on Laravel 11 and later, php artisan install:api creates the file). $request->getContent() is the raw body:

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Reeds\Exception\WebhookSignatureException;
use Reeds\Webhooks;

Route::post('/webhooks/reeds', function (Request $request, Webhooks $webhooks) {
    try {
        $event = $webhooks->constructEvent($request->getContent(), $request->header('Reeds-Signature'), config('services.reeds.webhook_secret'));
    } catch (WebhookSignatureException $e) {
        return response($e->getMessage(), 400);
    }
    // dispatch a job for $event['type'] ...
    return response()->noContent();
});

Lists and pagination

Every list returns one page, and foreach walks every item across pages:

$page = $reeds->shipping->deliveries->list(['status' => 'delivered', 'limit' => 50]);
$page->data;        // up to 50, newest first
$page->hasMore;     // true when another page follows
$page->nextPage();  // the next page, or null

foreach ($reeds->shipping->deliveries->list(['status' => 'delivered']) as $delivery) {
    // every delivered delivery; pages are fetched as you go
}

Errors

Every failure throws a Reeds\Exception\ReedsException subclass. Branch on the class or on $e->errorCode, never on the message, which Reeds can reword.

use Reeds\Exception\InvalidRequestException;
use Reeds\Exception\PaymentRequiredException;
use Reeds\Exception\ReedsException;

try {
    $reeds->shipping->deliveries->create(['quote_id' => $quote['id']]);
} catch (PaymentRequiredException $e) {
    // INSUFFICIENT_BALANCE: top up, then retry with the same idempotency_key
} catch (InvalidRequestException $e) {
    print_r($e->fields); // [['field' => 'dropoff.lga', 'message' => 'Enter the LGA.']]
} catch (ReedsException $e) {
    error_log("{$e->errorCode}: {$e->getMessage()} (request {$e->requestId}, see {$e->docUrl})");
    throw $e;
}
Exception Status Typical codes
AuthenticationException 401, or wrong or missing credentials TOKEN_EXPIRED, invalid_client, MISSING_CREDENTIALS
PermissionDeniedException 403 INSUFFICIENT_SCOPE, IP_NOT_ALLOWED, TEST_MODE_ONLY, ACCOUNT_ON_HOLD, ADDRESS_SEARCH_LIMIT_REACHED, ADDRESS_SEARCH_DISABLED
NotFoundException 404 NOT_FOUND
ConflictException 409 ACTION_NOT_ALLOWED, QUOTE_ALREADY_USED, QUOTE_EXPIRED, CANCEL_NOT_ALLOWED
InvalidRequestException 400, 422 INVALID_REQUEST (see fields), IDEMPOTENCY_KEY_REQUIRED
PaymentRequiredException 402 INSUFFICIENT_BALANCE, CREDIT_LIMIT_REACHED
RateLimitException 429 RATE_LIMITED ($retryAfter in seconds)
ServiceUnavailableException 503 SERVICE_UNAVAILABLE ($retryAfter in seconds)
ApiException other 5xx INTERNAL_ERROR
ConnectionException, ConnectionTimeoutException no response CONNECTION_FAILED, TIMEOUT
WebhookSignatureException a webhook that doesn't verify SIGNATURE_MISMATCH, TIMESTAMP_OUTSIDE_TOLERANCE, RAW_BODY_REQUIRED

Every exception has:

  • $status and $errorCode (getCode() stays the integer HTTP status);
  • $fields: what to fix, on validation errors;
  • $docUrl: the code's page on the developer hub;
  • $requestId: quote it to support;
  • $raw: the API's error object, including fields some codes add, such as delivery_id on QUOTE_ALREADY_USED.

Retries, idempotency and timeouts

Retried (up to max_retries, default 2) Never retried
No response (network error, timeout) 400, 401*, 402, 403, 404, 409, 422
429, 502, 503, 504, waiting for Retry-After (at most 30 s)
500 on a GET
409 REQUEST_IN_PROGRESS (the same key still running)

* An expired token is refreshed once and the request sent again, automatically.

Waits back off from about half a second, up to 8 seconds. Every POST carries an Idempotency-Key, and a retry sends the same one, so Reeds acts on it once. To make your own retries safe too (after a crash, or a queue retrying a job), pass a key tied to your order:

$reeds->shipping->deliveries->create(
    ['quote_id' => $quote['id'], 'reference' => 'ORDER-10045'],
    ['idempotency_key' => 'order-10045-delivery'],
);

The last argument of every method sets options for that request only: idempotency_key, timeout (seconds), max_retries and headers.

$reeds->shipping->deliveries->list(['limit' => 10], ['timeout' => 10, 'max_retries' => 0]);

Types and static analysis

Objects are arrays, and the library ships their shapes for PHPStan and Psalm in Reeds\Shipping\Types, generated from the API's OpenAPI spec. Your IDE and your analyser know every field. Import a shape into your own code:

/**
 * @phpstan-import-type Delivery from \Reeds\Shipping\Types
 */
final class Fulfilment
{
    /** @param Delivery $delivery */
    public function isDone(array $delivery): bool
    {
        return in_array($delivery['status'], ['delivered', 'cancelled'], true);
    }
}

Statuses and other enums are typed as plain strings, so a value Reeds adds later never breaks your analysis.

Configuration

new Reeds\Reeds([
    'client_id' => 'rci_test_...',          // default: REEDS_CLIENT_ID
    'client_secret' => 'rcs_test_...',      // default: REEDS_CLIENT_SECRET
    'base_url' => 'https://api.getreeds.co', // the default
    'timeout' => 60,                         // seconds per attempt
    'max_retries' => 2,
    'http_client' => new GuzzleHttp\Client(['proxy' => 'http://proxy.internal:8080']), // any Guzzle client
    'default_headers' => ['X-Your-Header' => 'value'],
]);

Versioning

The library follows semantic versioning. Until 1.0.0, a minor version may change something you use: read the changelog before upgrading.

Within an API version (v1), Reeds adds fields, statuses and event types without notice. The library keeps them in the arrays it returns.

Support

License

MIT © Reeds Merchants Technologies Ltd.