Laravel + SendCloud: verzendlabel API implementatie
Terug naar blog

Laravel + SendCloud: verzendlabel API implementatie

AuthorRuthger Idema
14 september 20267 min leestijd

Eén misgelopen verzendlabel kost je gemiddeld 7 tot 12 euro aan handmatige correctie, klantcontact en vertraagde levering. Bij 200 orders per dag tikt dat aan.

Laravel + SendCloud: verzendlabel API implementatie

Eén misgelopen verzendlabel kost je gemiddeld 7 tot 12 euro aan handmatige correctie, klantcontact en vertraagde levering. Bij 200 orders per dag tikt dat aan. De SendCloud API lost dit op, maar alleen als je de integratie goed bouwt: idempotent, met nette foutafhandeling en de juiste carrier-keuze per order.

Wij bouwen deze koppelingen in Laravel als maatwerk. Geen plugin die je 80% van de weg brengt en je daarna laat zitten. Hieronder de complete aanpak: van label genereren tot retouren, track & trace en het afvangen van fouten die je anders pas in productie ontdekt.

Waarom SendCloud en niet rechtstreeks de carrier

SendCloud is een laag boven PostNL, DHL, DPD, UPS, GLS en tientallen andere carriers. Je praat met één API in plaats van zes verschillende.

De winst:

  • Eén integratie voor meerdere vervoerders.
  • Carrier-onafhankelijke labels met dezelfde datastructuur.
  • Retourportalen out of the box.
  • Tracking webhooks die statussen normaliseren.

De prijs: je bent afhankelijk van hun rate limits en hun uptime. Voor de meeste webshops is dat een prima ruil. Verzend je 10.000+ pakketten per dag met één vaste carrier? Dan kan een directe koppeling goedkoper zijn. Wees daar eerlijk over voordat je begint.

API-authenticatie en config

SendCloud werkt met een Public en Secret key (Basic Auth). Zet ze nooit hardcoded in je code.

php
// config/services.php
'sendcloud' => [
    'public_key' => env('SENDCLOUD_PUBLIC_KEY'),
    'secret_key' => env('SENDCLOUD_SECRET_KEY'),
    'base_url'   => env('SENDCLOUD_BASE_URL', 'https://panel.sendcloud.sc/api/v2'),
],

Bouw een dunne client rond Laravel's HTTP-facade. Eén plek voor auth, timeouts en retries.

php
class SendCloudClient
{
    public function request(): PendingRequest
    {
        return Http::baseUrl(config('services.sendcloud.base_url'))
            ->withBasicAuth(
                config('services.sendcloud.public_key'),
                config('services.sendcloud.secret_key'),
            )
            ->timeout(15)
            ->retry(3, 200, throw: false);
    }
}

De retry(3, 200) vangt tijdelijke 5xx-fouten af. throw: false houdt de controle bij jou, zodat je zelf beslist wat een echte fout is.

Een verzendlabel genereren

Een label maken is in de basis één call naar /parcels. Het belangrijkste veld is request_label: true. Laat je dat weg, dan krijg je een parcel zonder label.

php
public function createParcel(Order $order): array
{
    $response = $this->client->request()->post('/parcels', [
        'parcel' => [
            'name'                 => $order->shipping_name,
            'address'              => $order->street,
            'house_number'         => $order->house_number,
            'city'                 => $order->city,
            'postal_code'          => $order->postal_code,
            'country'              => $order->country_iso, // 'NL'
            'email'                => $order->email,
            'telephone'            => $order->phone,
            'order_number'         => $order->number,
            'weight'               => $order->weight_kg,    // '1.250'
            'request_label'        => true,
            'shipment'             => ['id' => $order->shipping_method_id],
            'external_reference'   => (string) $order->id,
        ],
    ]);

    if ($response->failed()) {
        throw new SendCloudException($response->json('error.message'));
    }

    return $response->json('parcel');
}

De response bevat een label.label_printer URL. Die haal je apart op als PDF. Sla niet de URL op maar de parcel ID en de tracking number. URLs verlopen.

Het label downloaden

php
public function downloadLabel(int $parcelId): string
{
    return $this->client->request()
        ->get("/labels/normal_printer/{$parcelId}")
        ->body(); // ruwe PDF-bytes
}

Schrijf de bytes naar je storage of stream ze direct naar de browser. Bewaar het label in een private disk, niet publiek toegankelijk. Een verzendlabel bevat NAW-gegevens.

Carrier-selectie: shipping methods ophalen

Je kunt niet zomaar "PostNL" sturen. SendCloud werkt met shipping_method IDs die afhangen van je land, gewicht en contract.

php
public function availableMethods(string $toCountry, float $weight): array
{
    return $this->client->request()
        ->get('/shipping_methods', [
            'to_country'   => $toCountry,
            'sender_address' => $this->senderAddressId,
        ])
        ->json('shipping_methods');
}

Cache deze lijst. Hij verandert zelden en je wilt niet bij elke order een extra call doen.

php
$methods = Cache::remember(
    "sendcloud:methods:{$country}",
    now()->addHours(6),
    fn () => $this->availableMethods($country, $weight),
);
Selectie-logica bouw je zelf, op basis van businessregels:
ConditieCarrier-keuze
NL, < 23 kg, brievenbus mogelijkPostNL brievenbuspakje
NL, standaard pakketPostNL of DHL (op prijs)
BE/DEDPD of DHL
Buiten EUUPS / DHL Express

Hardcode niets in je controllers. Zet de regels in een dedicated CarrierSelector service die je los kunt testen.

Track & trace via webhooks

Polling op tracking-status is verspilling. SendCloud stuurt webhooks bij elke statuswijziging. Registreer één endpoint en verwerk de payload.

php
Route::post('/webhooks/sendcloud', WebhookController::class)
    ->withoutMiddleware(VerifyCsrfToken::class);

Verifieer altijd de signature. SendCloud tekent elke webhook met je secret key in de SendCloud-Signature header.

php
public function __invoke(Request $request)
{
    $signature = hash_hmac(
        'sha256',
        $request->getContent(),
        config('services.sendcloud.secret_key'),
    );

    abort_unless(
        hash_equals($signature, $request->header('SendCloud-Signature')),
        403,
    );

    $payload = $request->json('parcel');

    Parcel::where('sendcloud_id', $payload['id'])->update([
        'status'        => $payload['status']['message'],
        'status_code'   => $payload['status']['id'],
        'tracking_url'  => $payload['tracking_url'],
    ]);

    return response()->noContent();
}

Verwerk de zware logica (mail naar klant, status in je ERP) in een queued job, niet in de request. Webhooks moeten binnen seconden een 2xx teruggeven, anders gaat SendCloud opnieuw proberen en krijg je dubbele verwerking.

php
ProcessParcelStatus::dispatch($payload)->onQueue('webhooks');

Retouren met de retour-API

Retouren zijn waar veel integraties stoppen. Onterecht: een soepel retourproces verlaagt je klantcontact direct.

SendCloud biedt twee routes:

  1. Retourportaal — klant vraagt zelf een label aan via een hosted pagina.
  2. API-retour — je genereert programmatisch een retourlabel.

Voor controle over je flow kies je de API-route. Je maakt een nieuw parcel met is_return: true en de afzender/ontvanger omgedraaid.

php
public function createReturn(Order $order): array
{
    return $this->client->request()->post('/parcels', [
        'parcel' => [
            'name'           => $order->shipping_name,
            'address'        => $order->street,
            'house_number'   => $order->house_number,
            'city'           => $order->city,
            'postal_code'    => $order->postal_code,
            'country'        => $order->country_iso,
            'is_return'      => true,
            'request_label'  => true,
            'shipment'       => ['id' => $order->return_method_id],
            'weight'         => $order->weight_kg,
        ],
    ])->json('parcel');
}

Koppel het retourlabel aan de originele order via external_reference. Zo weet je administratie altijd welk pakket terugkomt en waarom.

Foutafhandeling die je redt in productie

De API faalt op manieren die je moet voorzien. Een paar die wij in de praktijk tegenkomen:

  • 422 Unprocessable — ongeldig adres of ontbrekend gewicht. Validatie vooraf scheelt 90% hiervan.
  • 429 Too Many Requests — je raakt de rate limit. Respecteer de Retry-After header.
  • 400 op shipping method — de gekozen methode bestaat niet voor dat land/gewicht. Daarom de fallback in je CarrierSelector.
  • Timeout — SendCloud reageert traag. Je retry-policy vangt dit, maar maak je calls idempotent.

Idempotentie is de belangrijkste. Gebruik external_reference met je eigen order-ID en check vóór het aanmaken of er al een parcel bestaat. Zo voorkom je dubbele labels (en dubbele verzendkosten) als een job opnieuw draait.

php
public function ensureLabel(Order $order): Parcel
{
    return DB::transaction(function () use ($order) {
        $existing = Parcel::where('order_id', $order->id)
            ->lockForUpdate()
            ->first();

        if ($existing?->sendcloud_id) {
            return $existing;
        }

        $parcel = $this->createParcel($order);

        return Parcel::updateOrCreate(
            ['order_id' => $order->id],
            ['sendcloud_id' => $parcel['id'], 'tracking_number' => $parcel['tracking_number']],
        );
    });
}

Log elke mislukte call met de volledige response-body in een aparte channel. Bij verzendproblemen wil je binnen een minuut weten wat er misging, niet de payload reconstrueren.

Labels genereren in batch via de queue

Bij volume genereer je labels niet synchroon bij het afrekenen. Je zet ze op een queue en verwerkt ze in batches richting het pickproces.

php
$orders->each(fn ($order) =>
    GenerateLabel::dispatch($order)->onQueue('labels')
);

Beperk de concurrency met WithoutOverlapping of een rate-limited queue worker. Anders ren je zo in de 429. Een nette opzet verwerkt 1000 labels per uur zonder een enkele rate-limit-fout.

Wil je weten of een SendCloud-koppeling in jouw stack past, of het slimmer is om direct met een carrier te integreren? Neem contact op — we kijken mee naar je ordervolume en carrier-mix voordat we ook maar één regel code schrijven.

Veelgestelde vragen

Heb ik SendCloud nodig of kan ik direct met PostNL koppelen?

Bij meerdere carriers of onder de ~5000 pakketten per dag is SendCloud bijna altijd sneller te bouwen en goedkoper in onderhoud. Eén API in plaats van per carrier een eigen koppeling. Verzend je zeer hoog volume met één vaste carrier, dan kan een directe integratie de transactiekosten verlagen. Reken het door op je eigen cijfers.

Hoe voorkom ik dubbele verzendlabels?

Maak je calls idempotent. Gebruik je eigen order-ID als external_reference en check binnen een database-transactie met lockForUpdate of er al een parcel bestaat voordat je een nieuw label aanvraagt. Zo levert een opnieuw gedraaide queue-job geen tweede label en geen dubbele verzendkosten op.

Werkt SendCloud ook met Magento of Shopify in plaats van Laravel?

Ja. SendCloud heeft kant-en-klare plugins voor Magento en Shopify. Die zijn prima voor standaard-flows. Heb je maatwerk nodig — specifieke carrier-regels, koppeling met je ERP of WMS — dan bouwen wij de API-integratie los in Laravel en koppelen die aan je shop.

Hoe houd ik track & trace-statussen actueel?

Gebruik webhooks, geen polling. Registreer één endpoint, verifieer de signature met HMAC-SHA256 en verwerk de payload in een queued job. Geef de webhook direct een 2xx terug en doe het zware werk (klantmail, ERP-update) asynchroon, anders stuurt SendCloud de webhook opnieuw.

Wat gebeurt er als de SendCloud API plat ligt?

Met een nette retry-policy vang je korte storingen automatisch op. Voor langere uitval zet je labelgeneratie op een queue met retries, zodat orders blijven wachten in plaats van te falen. Log elke fout met de volledige response zodat je direct ziet of het aan het adres, de carrier of de API zelf ligt.

Ruthger Idema

Geschreven door Ruthger Idema

15+ jaar ervaring in e-commerce development. Gespecialiseerd in Magento, Shopify en Laravel maatwerk.

Meer over ons team →
Deel dit artikel:

Wil je jouw e-commerce naar het volgende niveau?

Plan een vrijblijvend gesprek met onze experts over Magento, Shopify of Laravel maatwerk.

Plan een Tech Check