Webhooks

Get a JSON payload pushed to your server the moment something happens with one of your packets.

How it works

You register an endpoint on Embedded → Webhooks and pick which events you want. We POST a signed JSON payload to that URL within 8 seconds of the event firing. Each webhook has its own secret used to HMAC-sign the body, so you can verify the request really came from us.

Up to 5 webhooks per broker. Each fires synchronously — with all 5 active and all 8s timeouts, the worst-case wait on the carrier's "thanks" screen is ~40 seconds. Keep your handler fast or queue work on your side.

Events

EventWhen it fires
carrier.signed A carrier completes the form and clicks "Sign here." Fires before email verification — the submission is in status=1 (Pending verify).
carrier.verified The carrier clicks the verification link in their email. The submission flips to status=2 (Verified).
carrier.rejected You marked the submission as Rejected from the Carriers detail modal. status=3.
webhook.test You clicked the "Send test" paper-plane button on a webhook row in the Embedded page. Useful for end-to-end signature-verification testing.

Payload shape

Every event ships with the same envelope:

{
  "event": "carrier.signed",
  "sent_at": "2026-04-26T14:33:21+00:00",
  "broker_id": 42,
  "submission": {
    "id": 1138,
    "hashid": "8b3e4f...",
    "status": 1,
    "legal_name": "EVERGREEN SHIPPERS LLC",
    "dba_name": null,
    "mc_number": "896325",
    "usdot_number": "2569360",
    "phone": "+1 (509) 991-8269",
    "physical_address": "13323 N MAYFAIR LN",
    "physical_city": "SPOKANE",
    "physical_state": "WA",
    "physical_zip": "99208",
    "dispatcher_name": "Joe Carrier",
    "dispatcher_email": "dispatch@carrier.com",
    "dispatcher_phone": "+1 (719) 714-0509",
    "services_provided": ["Flatbed","Reefer"],
    "auth_fullname": "John W. Smith",
    "auth_email": "john@carrier.com",
    "auth_taxid": "88 - 7893200",
    "signed_at": "2026-04-26 14:33:18"
  }
}
Use the hashid as your idempotency key. If we ever retry (we don't today, but might in v2), you'll see the same hashid — ignore duplicates by checking what you've already processed.

Verifying the signature

Every request includes an X-CPL-Signature header containing the HMAC-SHA256 of the raw request body, keyed by your webhook's secret:

X-CPL-Signature: 8a4f...3d2e
Content-Type: application/json
User-Agent: CarrierPacket.Link-Webhook/1.0
Always verify before trusting a payload. Without verification, anyone who guesses your endpoint URL can pretend to be us. The check is two lines of code — do it.

Receiver: PHP

Drop this into a file like cpl-webhook.php on your server. Replace YOUR_WEBHOOK_SECRET with the value shown in the Embedded webhook modal (we show it in the green box right after you save).

<?php
$secret  = 'YOUR_WEBHOOK_SECRET';

// Read the RAW request body — never use $_POST or json_decode-then-rehash.
$payload = file_get_contents('php://input');

// Compute what the signature should be, then constant-time-compare to what we sent.
$expected = hash_hmac('sha256', $payload, $secret);
$received = $_SERVER['HTTP_X_CPL_SIGNATURE'] ?? '';
if(!hash_equals($expected, $received)){
    http_response_code(401);
    error_log('[cpl-webhook] bad signature');
    exit;
}

// Decode AFTER verifying.
$event = json_decode($payload, true);

switch($event['event']){
    case 'carrier.signed':
        // Insert into your TMS, send a Slack ping, etc.
        $sub = $event['submission'];
        error_log('New carrier: '.$sub['legal_name'].' MC#'.$sub['mc_number']);
        break;
    case 'carrier.verified':
        // Mark as fully onboarded
        break;
    case 'webhook.test':
        // No-op — just confirms the endpoint is reachable
        break;
}

http_response_code(200);

Receiver: Node.js (Express)

Same idea, but watch the express.raw() middleware — if you let Express's default JSON parser run first, it consumes the body and your HMAC will mismatch.

const express = require('express');
const crypto  = require('crypto');

const app    = express();
const SECRET = process.env.CPL_WEBHOOK_SECRET || 'YOUR_WEBHOOK_SECRET';

// IMPORTANT: read the raw body, not parsed JSON. Otherwise the HMAC won't match.
app.post('/cpl-webhook', express.raw({type: 'application/json'}), (req, res) => {
    const expected = crypto
        .createHmac('sha256', SECRET)
        .update(req.body)
        .digest('hex');
    const received = req.get('X-CPL-Signature') || '';

    // Constant-time compare to prevent timing attacks
    const a = Buffer.from(expected, 'utf8');
    const b = Buffer.from(received, 'utf8');
    if(a.length !== b.length || !crypto.timingSafeEqual(a, b)){
        return res.status(401).end();
    }

    const event = JSON.parse(req.body.toString());

    switch(event.event){
        case 'carrier.signed':
            console.log('New carrier:', event.submission.legal_name, 'MC#', event.submission.mc_number);
            // your TMS upsert / Slack ping / DB insert here
            break;
        case 'carrier.verified':
            // mark as fully onboarded
            break;
        case 'webhook.test':
            break;
    }

    res.status(200).end();
});

app.listen(3000, () => console.log('Webhook receiver up on :3000'));

Receiver: Python (Flask)

Same idea: read raw bytes, HMAC them, compare in constant time, then decode.

import hmac, hashlib, json
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = 'YOUR_WEBHOOK_SECRET'.encode('utf-8')

@app.post('/cpl-webhook')
def cpl_webhook():
    # IMPORTANT: get_data() returns raw bytes; don't use request.json
    # before verifying — it'd parse and you'd lose the canonical body.
    payload = request.get_data()

    expected = hmac.new(SECRET, payload, hashlib.sha256).hexdigest()
    received = request.headers.get('X-CPL-Signature', '')
    if not hmac.compare_digest(expected, received):
        abort(401)

    event = json.loads(payload)

    if event['event'] == 'carrier.signed':
        sub = event['submission']
        print(f"New carrier: {sub['legal_name']} MC#{sub['mc_number']}")
        # TMS upsert / Slack ping / DB insert here
    elif event['event'] == 'carrier.verified':
        # mark as fully onboarded
        pass
    elif event['event'] == 'webhook.test':
        pass

    return ('', 200)


if __name__ == '__main__':
    app.run(port=3000)

Testing your endpoint

The fastest end-to-end test:

  1. Stand up your receiver locally and expose it with ngrok (or use webhook.site for a quick listener).
  2. Add the public URL on Embedded → Webhooks. Subscribe to carrier.signed.
  3. Click the paper-plane "Send test" button on the row. Your endpoint should receive a {"event":"webhook.test", ...} payload immediately.
  4. The webhook row's Status column updates to show the HTTP code your endpoint returned. Green = success, red = failure (hover for the error message).

Troubleshooting

SymptomMost likely cause
Signature always mismatches You're hashing the parsed JSON instead of the raw body. Both the PHP and Node samples above read the raw bytes precisely for this reason. Express middleware order matters.
last_status shows 0 or "timeout" Your endpoint took longer than 8 seconds to respond. Move heavy work into a background job and return 200 fast.
"Send test" returns 200 but real events don't fire Make sure the webhook is Active and subscribed to the specific event. carrier.signed doesn't fire if you only subscribed to carrier.verified.
last_status = 526 / SSL error Your TLS cert is expired, self-signed, or chain-incomplete. We verify TLS; replace the cert.
"URL must point to a public host" rejection We block localhost, RFC1918 private ranges (10.x, 192.168.x, 172.16.x), and link-local (169.254.x) to prevent SSRF. Use ngrok or another public endpoint for local development.

Retries (or lack thereof)

v1 fires once and doesn't retry. If your endpoint returns a non-2xx response, the failure is logged on the webhook row's Last fire + Status columns. Use the "Send test" button to redeliver while you're debugging, and pull the historical record via the REST API if needed.

Automatic retries with exponential backoff are on the roadmap.