API integrations are the backbone of modern software. Every checkout, every workflow, and every AI agent with tools depends on systems talking to each other reliably. They are also where most production failures start: a timeout nobody planned for, an error hidden inside a successful response, or a webhook processed twice.

Over 13+ years of building systems that talk to payment gateways, billing platforms, CRMs, and messaging APIs, I have settled on a short list of patterns. LaunchLancer, our store builder for Pakistani merchants, is a good example of why: a store's checkout can take JazzCash, Safepay, Raast QR, bank transfer, or cash on delivery, and every gateway in that list fails in its own way. Here are the patterns, with Laravel code from this site.

1. One client class per provider

Never scatter raw HTTP calls through controllers, jobs, and workflow code. Each provider gets one class that owns the URL, the authentication, the request format, and the response parsing. Here is the WHMCS client behind the domain checker on this site:

class WhmcsClient
{
    public function __construct(
        protected string $url,
        protected string $identifier,
        protected string $secret,
    ) {}

    public function domainWhois(string $domain): DomainWhoisResult
    {
        try {
            $response = Http::asForm()->acceptJson()->post($this->url, [
                'action' => 'DomainWhois',
                'identifier' => $this->identifier,
                'secret' => $this->secret,
                'responsetype' => 'json',
                'domain' => $domain,
            ]);
        } catch (ConnectionException $e) {
            throw new WhmcsApiException("Could not reach the WHMCS API: {$e->getMessage()}", previous: $e);
        }

        // ...
    }
}

The rest of the app never sees a URL or an API secret. It calls domainWhois() and gets back a DomainWhoisResult. When WHMCS changes something, there is one file to change.

2. Return typed results and throw your own exceptions

The client turns three different failures into one domain exception:

  • The connection fails: ConnectionException becomes WhmcsApiException.
  • The HTTP status is an error: the client throws with the status code.
  • The HTTP status is 200 but the body says result: error: this one catches people out. WHMCS, like many older APIs, reports failure inside a successful response.
if (($data['result'] ?? null) !== 'success') {
    throw new WhmcsApiException($data['message'] ?? 'The WHMCS API returned an unsuccessful result.');
}

Callers handle one exception type and one result type. They never have to know how this particular provider expresses failure.

3. Set timeouts and retries on purpose

Laravel's HTTP client has a default timeout, but that default is a guess about your provider. Set timeouts per client based on how the API actually behaves, and retry only what is safe to retry:

Http::timeout(10)
    ->retry(3, 200, fn (Throwable $e) => $e instanceof ConnectionException)
    ->post($url, $payload);

A lookup can be retried freely. A request that creates something, such as a charge, a ticket, or an order, must not be retried unless the provider supports an idempotency key, or you will create it twice. Retries with backoff help with timeouts, rate limits, and brief outages. They are not a fix for a request the provider rejected.

4. Make side effects idempotent

Assume every message will arrive twice and every job will run twice, because eventually they will.

  • Use a unique reference per attempt for anything that creates a transaction, such as the order plus an attempt number. Many payment gateways refuse a reference they have already seen, paid or not.
  • Store external event IDs with a unique index, so a repeated webhook fails to insert instead of being processed again.
  • Write settlement code so it can run repeatedly. If the order is already paid, return early.

5. Treat webhooks as claims, not facts

A webhook tells you something may have happened. Handle it in four steps:

  1. Verify the signature, compared in constant time, and reject stale timestamps.
  2. Respond quickly and queue the real work. Providers retry when you are slow.
  3. Fetch the current state from the provider rather than trusting the payload.
  4. Process it idempotently.

The third step is the one most often skipped, and it is the one that stops a forged or out-of-order webhook from marking something paid. Our open-source Laravel SMS gateway package is built on the same rules, with signed webhooks and idempotent sends, and the payment bridge our products share applies them to card payments.

6. Log outbound calls, with secrets removed

When an integration misbehaves, the first question is always "what exactly did we send, and what came back?" On this site, a recorder listens to the HTTP client's events, so every outbound request made during an MCP request is stored with its status, duration, and bodies:

Event::listen(ResponseReceived::class, function (ResponseReceived $event): void {
    $this->app->make(OutboundApiRecorder::class)->handleResponse($event);
});

Before anything is stored, values under keys like secret, password, token, api_key, and authorization are redacted, and long bodies are truncated. A log that leaks credentials becomes its own incident.

7. Keep credentials in configuration and give them the least access

Credentials come from config, which reads the environment. They never appear in code, and they are never written into an n8n node's text field. On the provider's side, grant the integration only the actions it needs. WHMCS, for example, can limit an API role to specific actions and restrict API access by IP address.

8. Test against fakes, and assert on what was sent

Http::fake([
    '*' => Http::response(['result' => 'error', 'message' => 'Domain not valid'], 200),
]);

$response = DomainServer::tool(CheckDomainAvailabilityTool::class, ['domain' => 'bad-tld.invalidtld']);

$response->assertHasErrors();

Test the success path, an error inside a 200 response, an HTTP error, and an unreachable host. I describe the full testing setup in Testing AI Features in Laravel. Use Http::assertSent() to check the exact action and fields. Integration bugs are usually about what was sent, not what was received.

9. Isolate change

APIs change: endpoints get deprecated, fields get renamed, and authentication methods evolve. With one client class per provider and typed results, a provider change is a change to one class and its tests. Without that, it is a search through the whole codebase.

A note on n8n

The same patterns apply in n8n, as I describe in Designing Production-Grade n8n Workflows: use credentials rather than pasted keys, Retry On Fail on HTTP nodes that read, an error workflow for alerts, and validation before writing. When an integration grows real business logic, I move it into a Laravel service with tests and let n8n call that service. Workflows are excellent at orchestration. Complex integration logic is easier to keep correct in code.

My take

Every automation is only as strong as its weakest integration. None of these patterns is new or clever. They are what is left after enough incidents: one client per provider, failures translated into your own terms, retries only where safe, idempotency everywhere, webhooks verified and re-fetched, logs without secrets, and tests that check what you sent. Build them in on day one, because adding them after the first incident is always harder.