Your app takes a payment, but the invoice never reaches QuickBooks. A shipping label is created twice. The SMS provider has a bad afternoon and your checkout hangs with it. Most third-party API integrations work in the demo and break in production, because the real world sends timeouts, duplicates, expired tokens and events in the wrong order.
The short answer: a third-party API integration survives real life when every call has a timeout, failed calls are retried from a queue with backoff, writes are idempotent, webhooks are verified and processed once, and someone is alerted when the sync falls behind. This guide walks through each of those decisions, for business owners and CTOs connecting a web app to payment gateways, accounting, ERPs, shipping, SMS or a CRM.
For the basics, see our earlier post on third-party API integration in web applications. This one is about reliability.
What makes a third-party API integration break?
Almost every failure we see falls into one of these groups:
- Slow or unavailable provider. A call with no timeout ties up your server until the user gives up.
- Retries that duplicate work. The provider created the invoice, the response got lost, your code retried and created a second one.
- Expired or rotated credentials. An OAuth token expires on a weekend and nobody notices until Monday.
- Rate limits. A bulk import fires thousands of requests and the provider starts answering
429 Too Many Requests. - Webhooks you trust too much. Unsigned requests, duplicate deliveries and events that arrive out of order.
- Data that drifts. Two systems edit the same customer and nobody decided which one wins.
- Silent API changes. A field is deprecated, a version is retired, and the integration quietly stops syncing.
None of these are exotic. Plan for them up front and the integration becomes boring, which is the goal.
How do you choose the right API before writing code?
Before you commit to a provider, check:
| Check | Why it matters |
|---|---|
| Documentation and an official SDK | Clear docs cut build time; an SDK usually handles signing and versioning for you |
| Sandbox or test mode | You need a safe place to test failures, refunds and edge cases |
| Rate limits | Decides whether you can sync in real time or need batching |
| Webhooks | Push events beat polling, if they are signed and retried |
| Idempotency support | Lets you retry writes safely |
| Versioning policy | Tells you how often you will have to update the integration |
| Bulk or export endpoints | Needed for the first import and for reconciliation |
Stripe is a good benchmark: it supports idempotency keys, signs and retries webhooks and publishes a versioning policy. The weaker the API, the more of this safety you build on your side.
How should you handle authentication and secrets?
Most APIs use one of two models:
- API keys (a secret per account). Simple. Keep them out of the code and out of the repository, load them from environment configuration, and give each environment its own key.
- OAuth 2.0 (the customer connects their own account, common in accounting and CRMs). Your app stores an access token and a refresh token per connected account and refreshes the access token when it expires.
Two OAuth details cause most outages. First, the provider may issue a new refresh token each time you refresh, and the old one must be discarded, so save the new one in the same transaction. Second, a refresh can fail permanently (the customer revoked access), so show a "reconnect" message instead of retrying forever.
Store tokens encrypted at rest; in Laravel, the encrypted model cast does this with one line. Use the narrowest scopes the provider offers, and rotate secrets when people leave. The OWASP Secrets Management Cheat Sheet is a good checklist.
How do you make API calls reliable?
Always set a timeout
A request without a timeout can hang far longer than any user will wait. Laravel's HTTP client has timeout() and connectTimeout(); when the limit is exceeded it throws a ConnectionException you can handle.
Move calls out of the web request
If the user doesn't need the result to see the next page (syncing an invoice, sending an SMS, updating a CRM), dispatch a queued job. The user gets a fast response and the job retries in the background.
Retry with backoff, and only when it makes sense
Retry network errors, 5xx and 429. Don't retry a 422 validation error: it will fail the same way again. Space retries out (exponential backoff, ideally with jitter so thousands of jobs don't retry at the same second), and respect the Retry-After header that a 429 response may include.
Make writes idempotent
A retry is only safe if doing the work twice has the same effect as once. If the provider supports idempotency keys, send one per operation. Stripe, for example, saves the result of the first request with a given Idempotency-Key and returns that same result on retries, including errors; keys can be up to 255 characters and are pruned after at least 24 hours (Stripe docs). If the provider has no such feature, store the external ID as soon as you get it and check for it before creating anything.
Here is what that looks like in a Laravel 13 queued job:
namespace App\Jobs;
use App\Models\Invoice;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\Backoff;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Support\Facades\Http;
use Throwable;
#[Tries(5)]
#[Backoff([10, 60, 300, 900])]
class PushInvoiceToAccounting implements ShouldQueue
{
use Queueable;
public function __construct(public Invoice $invoice) {}
public function handle(): void
{
if ($this->invoice->external_id) {
return; // An earlier attempt already synced it.
}
$response = Http::withToken(config('services.accounting.token'))
->acceptJson()
->connectTimeout(5)
->timeout(15)
->withHeaders(['Idempotency-Key' => 'invoice-'.$this->invoice->id])
->post(config('services.accounting.url').'/invoices', $this->invoice->toAccountingPayload());
if ($response->clientError() && ! $response->tooManyRequests()) {
$this->fail($response->toException()); // Bad data: retrying won't help.
return;
}
$this->invoice->update(['external_id' => $response->throw()->json('id')]);
}
public function failed(?Throwable $exception): void
{
// Mark the invoice as "sync failed" and alert the team.
}
}
The Tries and Backoff attributes and the failed() method are documented in the Laravel queues guide. The Idempotency-Key header only helps if your provider supports it; check its docs for the exact header name.
Stop hammering a provider that is down
During a provider outage, every failing retry adds load on both sides. The circuit breaker pattern stops calls for a while after repeated failures. Laravel's ThrottlesExceptions job middleware does this for queued jobs, and RateLimited keeps you under a provider's request limits; both are in the queues documentation.
How do you handle webhooks safely?
Webhooks tell you something happened: a payment succeeded, a shipment moved. Treat every webhook as untrusted until verified.
- Verify the signature. Stripe signs each event in a
Stripe-Signatureheader and verification needs the raw request body (Stripe webhooks). Its libraries also reject events whose timestamp is more than 5 minutes old by default, which protects against replayed requests. - Respond fast, process later. Stripe asks you to return a
2xxbefore any complex logic and recommends processing events from a queue. - Expect duplicates. The same event can arrive more than once. Log processed event IDs and skip ones you have seen.
- Don't depend on order. Stripe does not guarantee events arrive in the order they were created. If you get "invoice paid" before "invoice created", fetch the current object from the API instead of guessing.
- Plan for your own downtime. In live mode Stripe retries failed deliveries for up to three days, so a short outage on your side is recoverable if your handler is idempotent.
A minimal Laravel endpoint using the official stripe/stripe-php library:
use App\Jobs\ProcessStripeEvent;
use App\Models\WebhookEvent;
use Illuminate\Http\Request;
use Stripe\Exception\SignatureVerificationException;
use Stripe\Webhook;
// routes/api.php (API routes have no CSRF check)
Route::post('/webhooks/stripe', function (Request $request) {
try {
$event = Webhook::constructEvent(
$request->getContent(),
$request->header('Stripe-Signature', ''),
config('services.stripe.webhook_secret'),
);
} catch (SignatureVerificationException|UnexpectedValueException) {
abort(400);
}
// Unique index on (provider, event_id) makes duplicates harmless.
$stored = WebhookEvent::firstOrCreate(
['provider' => 'stripe', 'event_id' => $event->id],
['type' => $event->type, 'payload' => $request->getContent()],
);
if ($stored->wasRecentlyCreated) {
ProcessStripeEvent::dispatch($stored);
}
return response()->noContent();
});
WebhookEvent and ProcessStripeEvent are your own model and job. Other providers sign differently (often an HMAC of the body); compare signatures with hash_equals(), never ==.
How do you keep data in sync between two systems?
Data mapping is where integrations quietly lose money. Decide these before building:
- Source of truth per field. Prices live in your app; payment status lives in the payment gateway; the customer's tax ID might live in accounting. Write it down.
- ID mapping. Store the external ID next to your own record (and yours in theirs, if the API allows custom fields or metadata).
- Conflict rule. When both sides changed a record, which wins? "Last write wins" is simple but loses data; for money, prefer "flag for review".
- Units and formats. Currencies in minor units vs decimals, time zones, tax-inclusive vs tax-exclusive totals, and status names that don't map one to one.
- Reconciliation. Even with webhooks, run a scheduled job that compares both sides (for example, yesterday's payments vs yesterday's invoices) and reports differences. Webhooks get missed; reconciliation catches it.
How do you test an integration without calling the live API?
Use three layers:
- Unit and feature tests with fakes. Laravel's
Http::fake()returns stubbed responses,Http::failedConnection()simulates a network failure,Http::sequence()returns a series of responses (say, two503s and then a200), andHttp::assertSent()checks what you sent.Http::preventStrayRequests()makes any unfaked request throw, so a test can never hit the real API by accident. - The provider's sandbox. Run the full flow, including refunds, declines, expired cards and webhook deliveries. Stripe's CLI can forward test webhooks to your local machine.
- A staging environment with its own credentials and webhook endpoints, never production keys.
Test the unhappy paths on purpose: timeouts, 429, invalid tokens, duplicates and out-of-order events.
How do you monitor an integration after launch?
A silent failure is worse than a loud one. At minimum:
- Log every call (provider, endpoint, status, duration, correlation ID), never card data, tokens or secrets.
- Alert on failed jobs and on jobs that hit their
failed()handler, not only on exceptions. - Track a sync lag metric, such as "invoices older than one hour without an external ID", and alert when it grows.
- Watch token health: connected accounts whose refresh failed should notify an admin and the customer.
- Give support a screen showing each record's sync status with a "retry" button.
What about API versions and deprecations?
Providers change their APIs. Pin the version you built against where the provider allows it (Stripe uses your account's default version unless you set a Stripe-Version header, and only its twice-yearly major releases contain breaking changes, per its versioning docs). Subscribe to the provider's changelog, read deprecation emails, and watch for the Sunset HTTP header some APIs send before retiring an endpoint. Keep all calls to one provider in one client class (see our post on API integrations with Saloon), so an upgrade touches one place.
Third-party API integration launch checklist
Before you switch an integration on in production, confirm:
- Live credentials are in environment configuration, separate from staging, and not in the repository.
- OAuth tokens are encrypted, refreshed automatically, and new refresh tokens are saved.
- Every outbound call has a connect timeout and a request timeout.
- Calls that don't need to block the user run in queued jobs with limited retries and backoff.
- Validation errors fail fast; network errors,
5xxand429are retried. - Every write is idempotent (idempotency key or stored external ID).
- Webhook signatures are verified, events are stored by ID, and processing happens in a queue.
- Field ownership, ID mapping and the conflict rule are written down.
- A scheduled reconciliation job compares both systems and reports differences.
- Tests cover timeouts,
429, duplicates and out-of-order events, with stray requests blocked. - Failed jobs and sync lag trigger alerts, and support can see and retry a record's sync.
- The API version is pinned and someone owns the provider's changelog.
How we build integrations at CodeBrisk
CodeBrisk was founded in 2014, our team lead has worked with Laravel since 2015, and integrations are part of most of what we build. In our fleet fuel card platform, weekly statements are generated by queued jobs, invoices are pushed to QuickBooks Online, and fuel transactions are linked to loads and units in the client's TMS. That transportation management system, also built by our team, turns delivered loads into QuickBooks invoices, matches fuel portal data to trucks and miles, and uses the Google Distance Matrix API.
Need to connect a payment gateway, accounting system or ERP, or fix an integration that keeps breaking? See our API development and integration service, then launch your project or contact us to talk it through.