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.intent | A carrier submits the onboarding form. The verification email is on its way but they haven't clicked the link yet — the submission is at status=0 (Awaiting email). The signed PDF doesn't exist yet. Useful for "a carrier started signing" notifications. |
| carrier.signed | The carrier clicks the verification link in their email and the PAdES signature gets minted on the server. Fires together with carrier.verified at the same instant; status=2 (Verified). The submission payload now includes signed_pdf_url. |
| carrier.verified | Same trigger as carrier.signed — the carrier clicked the verification link. The two events are kept distinct so brokers who only care about "the PDF exists" can subscribe to carrier.signed, and brokers who want to track the email-verification step separately can subscribe to carrier.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 Embedded → Webhooks. The payload carries "_synthetic": true so receivers can filter it out. Useful for end-to-end signature-verification testing. |
_synthetic. Both webhook.test and any event triggered from the docs explorer carry "_synthetic": true at the envelope and on the embedded submission. Naive receivers that write every event to a downstream system should check this flag and skip writes when it's true.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": 2,
"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",
"mailing_address": "PO BOX 14501",
"mailing_city": "SPOKANE",
"mailing_state": "WA",
"mailing_zip": "99214",
"dispatcher_name": "Joe Carrier",
"dispatcher_email": "dispatch@carrier.com",
"dispatcher_phone": "+1 (719) 714-0509",
"dispatcher_fax": "",
"dispatcher_url": "https://evergreenshippers.com",
"services_provided": ["Flatbed","Reefer"],
"services_notes": "53' van, Spokane-based",
"new_entrant_status": "",
"operating_status": "AUTHORIZED FOR Property",
"agreement_accepted": 1,
"auth_fullname": "John W. Smith",
"auth_email": "john@carrier.com",
"auth_taxid": "88 - 7893200",
"contract_datetime": "2026-04-26 14:33:00",
"signed_at": "2026-04-26 14:33:18",
"email_verified_at": "2026-04-26 14:33:21",
"ip_address": "76.135.204.132",
"agent_email": "",
"link_id": 0,
"signed_pdf_url": "https://carrierpacket.link/app/v.php?h=8b3e4f...&d=pdf"
}
}
Field reference:
| Field | Notes |
|---|---|
| id integer | Sequential submission id. Use as your foreign key. |
| hashid string (64-char hex) | Globally unique unguessable id. Use as your idempotency key — also the lookup key for /app/v.php?h=<hashid>. |
| status int | 0 Awaiting email · 2 Verified · 3 Rejected · 4 Archived. (1 is a legacy state from before PAdES; new submissions never land at 1.) See Concepts → Submission. |
| signed_pdf_url string · nullable | Direct URL to the signed PAdES PDF. Present on carrier.signed and carrier.verified; absent on carrier.intent (signature not minted yet) and carrier.rejected. |
| services_provided array of string | JSON-decoded from the DB column. Example: ["Flatbed","Reefer"]. |
| contract_datetime, signed_at, email_verified_at datetime | Render time, signature time, email-verify time. The latter two are null until the corresponding event fires. |
| link_id int · nullable | If the carrier arrived via a /a/<slug> attribution link, this is the link's id. 0 otherwise. |
| agent_email string · nullable | If the link they used had ?agent=<email>, the value lands here. |
| ip_address, user_agent, operating_system string | Audit fields captured at form submit time. |
The full payload mirrors what the REST API returns under GET /v1/submissions/{id}, with two intentional exclusions: signature_envelope (large JSON audit blob) and email_verification_key (the OTP — one-shot, never expose) are stripped before delivery. The signed_pdf_path filesystem path is rewritten as signed_pdf_url for the same reason.
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).
Trigger one right now
If you're logged into a broker account, pick a webhook subscription and an event — we'll fire it at your endpoint using your real HMAC secret. No fake row lands in your CPL submissions table.
carrier.* events go to the URL you configured on the selected webhook. The payload carries "_synthetic": true — if your receiver doesn't check for that flag, you'll get a "DOCS-EXPLORER-TEST" row in whatever downstream system you're piping into (TMS, CRM, Slack). The webhook.test event is always safe.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.