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
| Event | When 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"
}
}
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
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:
- Stand up your receiver locally and expose it with ngrok (or use webhook.site for a quick listener).
- Add the public URL on Embedded → Webhooks. Subscribe to
carrier.signed. - Click the paper-plane "Send test" button on the row. Your endpoint should receive a
{"event":"webhook.test", ...}payload immediately. - 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
| Symptom | Most 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.