Outbound Webhooks

Receive real-time HTTP notifications when events occur in the system.

Overview

Outbound webhooks allow you to receive real-time notifications via HTTP POST requests when vehicles and orders change status. Instead of polling the API for updates, you register a URL and we send event data to it automatically.

Each webhook request includes an HMAC-SHA256 signature so you can verify the request is authentic, a timestamp to prevent replay attacks, and a unique delivery ID for idempotency.

How it works: Register a webhook URL → Subscribe to events → We send POST requests to your URL when events occur → You verify the signature and process the data.

Authentication

All webhook management API requests require a valid Sanctum API token with the PUBLIC_API permission.

Include your token in the Authorization header:

Authorization: Bearer your-api-token

Quick Start

1. Register a webhook

POST /api/webhooks
Content-Type: application/json
Authorization: Bearer your-api-token

{
  "url": "https://your-app.com/webhooks/carpool",
  "events": ["vehicle.picked_up", "vehicle.delivered", "order.created"],
  "description": "Production webhook"
}

The response includes a secret field. Save this immediately — it is only returned once and is used to verify webhook signatures.

2. Send a test event

POST /api/webhooks/{webhook_id}/test
Authorization: Bearer your-api-token

This sends a test vehicle.created event to your URL so you can verify everything is working.

3. Verify the signature

Check the X-Webhook-Signature header against the payload to confirm authenticity. See Verifying Signatures for details.

API Endpoints

Method Endpoint Description
GET /api/webhooks List your registered webhooks
POST /api/webhooks Register a new webhook
GET /api/webhooks/{id} Get webhook details
PATCH /api/webhooks/{id} Update webhook (URL, events, status)
DELETE /api/webhooks/{id} Delete a webhook
GET /api/webhooks/{id}/deliveries List delivery history
POST /api/webhooks/{id}/test Send a test event
GET /api/webhook-events List all available event types

Register a webhook

POST /api/webhooks

{
  "url": "https://your-app.com/webhooks",
  "events": ["vehicle.picked_up", "vehicle.delivered"],
  "description": "My webhook"
}

// Response (201 Created)
{
  "status": true,
  "status_code": 201,
  "data": {
    "id": "a7k3m9x2n4p1q",
    "url": "https://your-app.com/webhooks",
    "secret": "kJ9mN2pR4...",
    "events": ["vehicle.picked_up", "vehicle.delivered"],
    "is_active": true,
    "description": "My webhook",
    "created_at": "2026-02-08T12:00:00+00:00"
  }
}

Update a webhook

PATCH /api/webhooks/{id}

{
  "url": "https://your-app.com/webhooks/v2",
  "events": ["vehicle.picked_up", "vehicle.delivered", "order.created"],
  "is_active": false
}

Delivery history fields

Each delivery from GET /api/webhooks/{id}/deliveries includes event_type, entity_type, status, attempt_count, response_status, delivered_at, and next_retry_at.

Available Events

Event Name Entity Description
vehicle.created Vehicle A new vehicle has been added to an order
vehicle.picked_up Vehicle A vehicle has been picked up by the carrier
vehicle.delivered Vehicle A vehicle has been delivered to the destination
vehicle.canceled Vehicle A vehicle has been canceled
vehicle.pickup_inspection_completed Vehicle Pickup inspection has been completed (includes inspection details and photos)
vehicle.delivery_inspection_completed Vehicle Delivery inspection has been completed (includes inspection details and photos)
order.created Order A new order has been created
order.picked_up Order All active vehicles in the order have been picked up (aggregate event)
order.delivered Order All active vehicles in the order have been delivered (aggregate event)
order.canceled Order An order has been canceled
Aggregate events: order.picked_up and order.delivered fire only when all active vehicles in an order reach the corresponding status. Vehicles with exception statuses (hold, canceled, dry run) are excluded from the calculation.

Payload Structure

Every webhook delivery sends an HTTP POST with a JSON body wrapped in a common envelope:

  • id — unique delivery identifier (also sent in the X-Webhook-Id header)
  • event — the event name (e.g. vehicle.picked_up)
  • timestamp — ISO-8601 time the event was dispatched
  • entity — the entity that triggered the event, with its type (Vehicle or Order) and slug id
  • data — the entity payload; its shape depends on entity.type (see the per-event examples below)

Vehicle event payload

Vehicle events (vehicle.created, vehicle.picked_up, vehicle.delivered, vehicle.canceled) have an entity.type of Vehicle:

{
  "id": "a7k3m9x2n4p1q",
  "event": "vehicle.picked_up",
  "timestamp": "2026-02-08T12:00:00+00:00",
  "entity": {
    "type": "Vehicle",
    "id": "9f8b0e6c4d1e4b3aaf2c5d6e7f8a9b0c"
  },
  "data": {
    "id": "9f8b0e6c4d1e4b3aaf2c5d6e7f8a9b0c",
    "vin": "1HGBH41JXMN109186",
    "status": "picked_up",
    "year": 2024,
    "make": "Honda",
    "model": "Accord",
    "color": "Black",
    "operable": true,
    "oversized": false,
    "rate": 850.00,
    "tracking_url": "https://...",
    "customer_id": "c1a2b3d4e5f6a7b8c9d0e1f2a3b4c5d6",
    "order_id": "0a1b2c3d4e5f60718293a4b5c6d7e8f9",
    "pick_up_date_actual": "2026-02-08T10:30:00+00:00",
    "delivery_date_actual": null,
    "created_at": "2026-02-01T08:00:00+00:00",
    "updated_at": "2026-02-08T10:30:00+00:00"
  }
}

Order event payload

Order events (order.created, order.picked_up, order.delivered, order.canceled) have an entity.type of Order and a data block that includes nested origin / destination objects and a vehicles array:

{
  "id": "b8l4n0y3o5q2r",
  "event": "order.created",
  "timestamp": "2026-02-08T12:00:00+00:00",
  "entity": {
    "type": "Order",
    "id": "0a1b2c3d4e5f60718293a4b5c6d7e8f9"
  },
  "data": {
    "id": "0a1b2c3d4e5f60718293a4b5c6d7e8f9",
    "customer_id": "c1a2b3d4e5f6a7b8c9d0e1f2a3b4c5d6",
    "reference_number": "REF-12345",
    "origin": {
      "name": "Origin Auto Auction",
      "address": "123 Main St",
      "city": "Newark",
      "state": "NJ",
      "postal_code": "07102"
    },
    "destination": {
      "name": "Destination Dealership",
      "address": "456 Market St",
      "city": "San Francisco",
      "state": "CA",
      "postal_code": "94103"
    },
    "vehicles": [
      {
        "id": "9f8b0e6c4d1e4b3aaf2c5d6e7f8a9b0c",
        "vin": "1HGBH41JXMN109186",
        "status": "ordered",
        "year": 2024,
        "make": "Honda",
        "model": "Accord",
        "color": "Black",
        "operable": true,
        "oversized": false,
        "rate": 850.00,
        "tracking_url": "https://...",
        "customer_id": "c1a2b3d4e5f6a7b8c9d0e1f2a3b4c5d6",
        "order_id": "0a1b2c3d4e5f60718293a4b5c6d7e8f9",
        "pick_up_date_actual": null,
        "delivery_date_actual": null,
        "created_at": "2026-02-01T08:00:00+00:00",
        "updated_at": "2026-02-01T08:00:00+00:00"
      }
    ],
    "created_at": "2026-02-01T08:00:00+00:00",
    "updated_at": "2026-02-01T08:00:00+00:00"
  }
}

Inspection event payload

Inspection events (vehicle.pickup_inspection_completed, vehicle.delivery_inspection_completed) are vehicle-level events — entity.type is Vehicle and the data block contains the full vehicle payload with an additional inspection object:

{
  "id": "c9m5o1z4p6r3s",
  "event": "vehicle.pickup_inspection_completed",
  "timestamp": "2026-02-08T10:30:00+00:00",
  "entity": {
    "type": "Vehicle",
    "id": "9f8b0e6c4d1e4b3aaf2c5d6e7f8a9b0c"
  },
  "data": {
    "id": "9f8b0e6c4d1e4b3aaf2c5d6e7f8a9b0c",
    "vin": "1HGBH41JXMN109186",
    "status": "picked_up",
    "year": 2024,
    "make": "Honda",
    "model": "Accord",
    "color": "Black",
    "operable": true,
    "oversized": false,
    "rate": 850.00,
    "tracking_url": "https://...",
    "customer_id": "c1a2b3d4e5f6a7b8c9d0e1f2a3b4c5d6",
    "order_id": "0a1b2c3d4e5f60718293a4b5c6d7e8f9",
    "pick_up_date_actual": "2026-02-08T10:30:00+00:00",
    "delivery_date_actual": null,
    "created_at": "2026-02-01T08:00:00+00:00",
    "updated_at": "2026-02-08T10:30:00+00:00",
    "inspection": {
      "id": 456,
      "type": "pickup",
      "status": "complete",
      "vin_last_6": "109186",
      "damages": [
        { "code": "scratched", "location": "front", "position": { "x": 100, "y": 50 } }
      ],
      "photos": [
        { "id": 1, "type": "front", "url": "https://s3.../signed-url" },
        { "id": 2, "type": "rear", "url": "https://s3.../signed-url" }
      ],
      "signatures": {
        "customer": "https://s3.../signed-url",
        "driver": "https://s3.../signed-url"
      },
      "created_at": "2026-02-08T10:00:00+00:00",
      "updated_at": "2026-02-08T10:30:00+00:00"
    }
  }
}

Verifying Signatures

Every webhook request includes three security headers:

Header Description
X-Webhook-Signature HMAC-SHA256 signature of timestamp.payload
X-Webhook-Timestamp Unix timestamp (for replay attack prevention)
X-Webhook-Id Unique delivery ID (for idempotency)

To verify a signature:

  1. Compute HMAC-SHA256(secret, timestamp + "." + raw_body)
  2. Compare the result to the X-Webhook-Signature header using a timing-safe comparison
  3. Verify the timestamp is within 5 minutes of the current time

PHP

$secret = 'your-webhook-secret';
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'];
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'];
$payload = file_get_contents('php://input');

// Verify timestamp is within 5 minutes
if (abs(time() - (int) $timestamp) > 300) {
    http_response_code(401);
    exit('Timestamp expired');
}

// Compute expected signature
$expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);

// Timing-safe comparison
if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('Invalid signature');
}

// Process the webhook
$data = json_decode($payload, true);
// ...

Python

import hmac, hashlib, time

secret = b'your-webhook-secret'
signature = request.headers['X-Webhook-Signature']
timestamp = request.headers['X-Webhook-Timestamp']
payload = request.get_data(as_text=True)

# Verify timestamp is within 5 minutes
if abs(time.time() - int(timestamp)) > 300:
    abort(401, 'Timestamp expired')

# Compute expected signature
expected = hmac.new(secret, f'{timestamp}.{payload}'.encode(), hashlib.sha256).hexdigest()

# Timing-safe comparison
if not hmac.compare_digest(expected, signature):
    abort(401, 'Invalid signature')

# Process the webhook
data = request.get_json()
# ...

Node.js

const crypto = require('crypto');

const secret = 'your-webhook-secret';
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
const payload = req.rawBody; // Ensure raw body is available

// Verify timestamp is within 5 minutes
if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) {
  return res.status(401).send('Timestamp expired');
}

// Compute expected signature
const expected = crypto
  .createHmac('sha256', secret)
  .update(`${timestamp}.${payload}`)
  .digest('hex');

// Timing-safe comparison (guard against length mismatch)
const expectedBuf = Buffer.from(expected, 'hex');
const signatureBuf = Buffer.from(signature, 'hex');
if (expectedBuf.length !== signatureBuf.length || !crypto.timingSafeEqual(expectedBuf, signatureBuf)) {
  return res.status(401).send('Invalid signature');
}

// Process the webhook
const data = JSON.parse(payload);
// ...

Retry Behavior

If your endpoint returns a non-2xx status code or the connection times out, the delivery is marked as failed and retried automatically.

Setting Value
Retry interval 5 minutes
Maximum attempts 10
Connection timeout 10 seconds
Request timeout 30 seconds

After 10 failed attempts, the delivery is marked as exhausted and will not be retried. You can monitor delivery status via the GET /api/webhooks/{id}/deliveries endpoint.

Duplicate events for the same entity may be suppressed within a short deduplication window (default 5 minutes) to avoid rapid status oscillation generating multiple deliveries.

Delivery Statuses

Status Description
pending Delivery has been created and is awaiting HTTP execution
delivered Your endpoint returned a 2xx response
failed Your endpoint returned a non-2xx response or the connection timed out. Will be retried.
exhausted Maximum retry attempts reached. No further retries will be made.

Error Handling

Your endpoint should return a 2xx status code to acknowledge receipt. Any other status code is treated as a failure.

Best practices

  • Return 200 OK as quickly as possible. Process the webhook data asynchronously if needed.
  • Use the X-Webhook-Id header for idempotency. Store processed IDs to avoid handling duplicate deliveries.
  • Always verify the signature before processing webhook data.
  • Handle the timestamp check to prevent replay attacks.

Common API errors

Status Code Cause
401 Unauthenticated Missing or invalid API token
403 Forbidden Token lacks PUBLIC_API permission
404 Not Found Webhook does not exist or you don't have access
422 Validation Error Invalid request data (check the errors field)

Testing Locally

During development, you'll need a way to receive webhook requests on your local machine. Here are three approaches depending on your needs.

Webhook Inspection Tools

The simplest approach is to use a hosted URL that captures and displays incoming requests. These tools require no installation and let you inspect headers, payloads, and signatures immediately.

Tool URL Notes
webhook.site webhook.site Free. Generates a unique URL instantly. Shows headers, body, and query params.
Pipedream RequestBin pipedream.com/requestbin Free. Captures requests with a shareable URL. Also supports workflows to process incoming data.

Quick start with webhook.site:

  1. Go to webhook.site and copy your unique URL
  2. Register a webhook using the URL:
    POST /api/webhooks
    
    {
      "url": "https://webhook.site/your-unique-id",
      "events": ["vehicle.created", "vehicle.picked_up"]
    }
  3. Send a test event: POST /api/webhooks/{id}/test
  4. Check webhook.site — you should see the request with:
    • X-Webhook-Signature header
    • X-Webhook-Timestamp header
    • X-Webhook-Id header
    • JSON body with id, event, timestamp, entity, and data

Tunneling to Localhost with ngrok

If you need to test your actual webhook handler code running locally, use ngrok to expose your local server to the internet via a public URL.

Setup:

  1. Install ngrok from ngrok.com/download and authenticate with your free account
  2. Start a tunnel to your local webhook handler:
    ngrok http 8080
  3. Copy the generated HTTPS URL (e.g., https://a1b2c3d4.ngrok-free.app)
  4. Register a webhook using the ngrok URL:
    POST /api/webhooks
    
    {
      "url": "https://a1b2c3d4.ngrok-free.app/webhooks/carpool",
      "events": ["vehicle.picked_up", "order.created"]
    }
  5. Webhook requests will now be forwarded to http://localhost:8080/webhooks/carpool
Tip: ngrok provides a local dashboard at http://127.0.0.1:4040 where you can inspect all requests flowing through the tunnel, replay them, and debug your handler.