Skip to main content

Every carrier is different. The shape of the problem isn't.

00:05:09

Over about four years I built and ran more than twenty direct-carrier-billing integrations across roughly ten countries: subscriptions, one-off charges, and millions of asynchronous billing callbacks. Every carrier was different. Different APIs, different authentication, different notification formats, different ideas about what a "successful charge" even looks like on the wire.

The first couple of integrations, you write a class per carrier and move on. By the fifth, your billing service has grown a few if ($carrier === ...) branches. By the tenth, nobody on the team can tell you with confidence what happens when a given carrier sends a renewal versus a first charge, because that knowledge is smeared across six files and two people's memories.

The differences are all at the edges

What took me too long to see is that the differences are almost entirely at the boundary. Strip away the wire formats and every carrier integration is the same small set of operations: subscribe a number, charge it, cancel it. Plus a stream of async notifications: activated, renewed, charged, out of balance, unsubscribed. That is the whole domain. The carrier-specific noise is just translation, and translation belongs in exactly one place.

So the job is to do that translation once per carrier and keep it out of everything else. Three small pieces get you there.

One vocabulary

Every carrier has its own status codes. SUB_OK, BILL_OK, NO_FUND, ACTIVATION, RENEWAL, and a pile of numeric codes if you're unlucky. Your application should never see any of them. It should see this:

php
namespace Gardi\DcbKit\Callbacks;

enum CallbackType: string
{
    case Subscribed = 'subscribed';
    case Renewed = 'renewed';
    case Charged = 'charged';
    case InsufficientBalance = 'insufficient_balance';
    case Unsubscribed = 'unsubscribed';
    case Failed = 'failed';
    case Unknown = 'unknown';
}

Seven cases. A real carrier might distinguish thirty notification types, but for the question that actually matters (what do I do about this?), most of them collapse into one of these. Unknown is deliberate. A carrier will eventually send you something you've never seen, and the correct behaviour is to record it and carry on, not to throw an exception in the middle of a billing webhook.

One interface per carrier

Then a single contract, implemented once per carrier:

php
namespace Gardi\DcbKit\Contracts;

use Gardi\DcbKit\Callbacks\CallbackEvent;
use Gardi\DcbKit\Money;
use Gardi\DcbKit\Results\ChargeResult;
use Gardi\DcbKit\Results\SubscriptionResult;

interface CarrierGateway
{
    /** Carrier key, e.g. "mtn-ghana". */
    public function name(): string;

    /** Subscribe an MSISDN (mobile number) to a recurring billing plan. */
    public function subscribe(string $msisdn, string $plan): SubscriptionResult;

    /** Charge a one-off amount. `$reference` is the caller's idempotency key. */
    public function charge(string $msisdn, Money $amount, string $reference): ChargeResult;

    /** Cancel a subscription. */
    public function unsubscribe(string $msisdn, string $subscriptionId): void;

    /** Normalize a raw async carrier notification into a CallbackEvent. */
    public function parseCallback(array $payload): CallbackEvent;

    /** Verify a callback is genuinely from the carrier, against the raw request body. */
    public function verifyCallback(string $rawBody, string $signature): bool;
}

Everything your app does to a carrier goes through this. The interesting method is parseCallback: it turns a raw notification into a normalized event, and the carrier-specific translation lives behind it and nowhere else. For a carrier with a genuinely odd API, you write the class, and the translation is a match:

php
public function parseCallback(array $payload): CallbackEvent
{
    $type = match ($payload['event']) {
        'SUB_OK'  => CallbackType::Subscribed,
        'BILL_OK' => CallbackType::Charged,
        'NO_FUND' => CallbackType::InsufficientBalance,
        default   => CallbackType::Unknown,
    };

    return new CallbackEvent($type, $payload['msisdn'], raw: $payload);
}

SUB_OK becomes Subscribed, NO_FUND becomes InsufficientBalance, and anything unrecognized becomes Unknown. The raw payload rides along on the event, so you can still reach a carrier-specific field on the rare occasion you need one, but the rest of your code branches on the normalized type. The question your billing logic asks gets to stay this simple:

php
$event = $gateway->parseCallback($payload);

if ($event->isSuccessful()) {
    // confirm the subscription or charge
}

Where isSuccessful() is just the normalized types that mean money moved:

php
public function isSuccessful(): bool
{
    return in_array(
        $this->type,
        [CallbackType::Subscribed, CallbackType::Renewed, CallbackType::Charged],
        true,
    );
}

Most carriers don't even need a class

Here's the part that paid off the most. Once you've written a handful of these, you notice that most carriers don't need a class at all. They are a base URL, an auth scheme, a way of signing callbacks, and a table of status codes. That table is data, not code:

php
namespace Gardi\DcbKit\Callbacks;

final class StatusMap
{
    private function __construct(private readonly array $map) {}

    public static function make(array $map): self
    {
        return new self($map);
    }

    public function resolve(string $code): CallbackType
    {
        return $this->map[$code] ?? CallbackType::Unknown;
    }
}

Note the ?? CallbackType::Unknown again: an unmapped code is a known state, not a crash. Once the status table is data, a whole carrier becomes a config entry, and you can build the manager straight from an array:

php
use Gardi\DcbKit\CarrierManager;

$carriers = CarrierManager::fromArray([
    'acme' => [
        'base_url' => 'https://api.acme.test',
        'auth'     => ['type' => 'bearer', 'token' => $config['acme']['token']],
        'verifier' => ['type' => 'hmac', 'secret' => $config['acme']['secret']],
        'statuses' => [
            'ACTIVATION' => 'subscribed',
            'BILL_OK'    => 'charged',
            'NO_FUNDS'   => 'insufficient_balance',
            'CANCEL'     => 'unsubscribed',
        ],
        'status_field' => 'event',
        'msisdn_field' => 'phone',
    ],
    // 'mtn' => [ ... ], 'zain' => [ ... ]
], new GuzzleTransport());

That maps straight onto a Laravel or Symfony config file, which means adding the next carrier is a config edit, not a code deploy. The carriers with genuinely strange behaviour still get a class and override the one method that differs. The win is that "strange" becomes an exception you handle on purpose, instead of the default you brace for every time.

What this buys you

The payoff isn't tidiness for its own sake. It's that your billing code stops caring which carrier it's talking to, so the carrier count stops driving the complexity. Integration number twenty costs about what integration number three did, because the only new thing is a status table and maybe one overridden method.

I pulled this shape out into a small PHP package called dcb-kit. It's zero-dependency and framework-agnostic, and it is deliberately not much: the enum, the interface, the status map, idempotent charging, and signature verification against the raw body.

bash
composer require gardi/dcb-kit

What it pointedly does not ship is a single real carrier adapter. Those are the proprietary, NDA-bound part, and they belong with the platforms that own them. The reusable thing was never the integrations themselves. It was the shape they all share.

If you're staring down your third carrier integration and watching the if statements multiply, that shape is the thing worth extracting, package or no package.