---
title: "Webhooks - Old Cars Data"
description: "Receive signed auction and bid events and manage delivery retries."
url: "https://oldcarsdata.com/docs/webhooks"
dateModified: "2026-10-17T00:00:00.000Z"
type: "api-docs"
tags: ["API", "webhooks", "documentation"]
---

# Webhooks

Receive signed JSON events when Old Cars Data observes auction changes or first ingests a bid. Create and manage the same subscriptions through the public API or [Account → Webhooks](/profile/webhooks). Changes made through either interface appear in the other.

A verified email address is required. Creating, editing, or resuming subscriptions, rotating secrets, sending tests, and retrying failed deliveries require an active **1K, 10K, or 100K plan**. After a downgrade, you can still inspect, pause, and delete existing subscriptions. Endpoint allowances are **1 endpoint on the $49/month 1K plan**, **10 endpoints on the $199/month 10K plan**, and **unlimited endpoints on the $699/month 100K plan**. Paused endpoints count toward the allowance when creating another endpoint. Each endpoint can subscribe to multiple event types and filters; these limits count destinations, rather than deliveries.

## Create a Subscription

Deploy a public HTTPS endpoint that accepts JSON `POST` requests on port 443. Private or reserved networks, localhost, URL credentials, fragments, and redirects are unsupported.

In the account UI, select **Add endpoint**, choose event types, and optionally filter by auction ID, make, and source. Or use your existing API key:

```bash
curl -X POST https://api.oldcarsdata.com/webhooks \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-domain.com/webhooks",
    "description": "Track my auction",
    "event_types": ["bid.created", "auction.updated", "auction.ended"],
    "filters": { "auction_id": "12345" }
  }'
```

The response contains the subscription in `data` and its one-time signing `secret`. Save the secret securely on your server; listing or retrieving subscriptions never reveals it again.

```json
{
  "data": {
    "id": "whe_5dbfd308-d0b7-4c11-b0d9-fd2ca67cbe7a",
    "url": "https://your-domain.com/webhooks",
    "description": "Track my auction",
    "event_types": ["bid.created", "auction.updated", "auction.ended"],
    "filters": { "auction_id": "12345" },
    "enabled": true,
    "created_at": "2026-10-18T18:00:00Z",
    "updated_at": "2026-10-18T18:00:00Z"
  },
  "secret": "whsec_BASE64_SIGNING_KEY"
}
```

## Events and Filters

| Event | Trigger |
| --- | --- |
| `auction.created` | A new eligible source auction row is first persisted. |
| `auction.updated` | Meaningful stored auction fields change, excluding changes reported as `auction.ended`. |
| `auction.ended` | The source reports a transition from `active` or `coming soon` into `sold`, `reserve not met`, `result unavailable`, `canceled`, or `withdrawn`. |
| `bid.created` | A newly persisted bid row is first observed, including a historical bid newly ingested during a backfill. |
| `webhook.test` | An explicit test is requested through the account UI or API. This type cannot be selected as a subscription event. |

Auction changes include title, status, price, currency, year, make, model, auction end time/date, bid count, sale type, and price type. Scrape timestamps, views, watchers, and comment counts do not trigger updates. The initial and final state within a transaction determine the event: a change reversed before commit produces no event. An ended transition produces `auction.ended` rather than a second `auction.updated` for that change.

Passing the scheduled end time does not itself fire an event. Updates caused only by normalization do not fire events. Capture happens atomically with committed source writes; normalization runs separately. A normalization rebuild does not generate another set of webhook events. Our API may briefly lag the source observation represented by a webhook.

Optional filters combine with **AND**:

| Filter | Behavior |
| --- | --- |
| `auction_id` | Exact auction API ID, supplied as a positive integer **string**. Bid events match their captured parent auction ID. |
| `make` | Exact, case-insensitive canonical make name, such as `Porsche`. |
| `source` | Exact, case-insensitive source name, such as `bringatrailer` or `carsandbids`. |

An omitted filter accepts every value. Model filtering is unsupported. Keep IDs as strings; converting them to JavaScript numbers can lose precision. Bid make/source filters use the parent auction's routing context. Canonical make routing uses an existing mapping or an unambiguous exact canonical name match. An unknown make keeps the observation pending only when an otherwise matching subscription needs a make filter; auction-only and source-only subscriptions do not wait for make maintenance. Required missing parent or source context also keeps an observation pending. Confirmed source exclusions and aliases are suppressed. Bid events follow their parent auction's eligibility rules, including exclusion of parent auctions ending before 2016; an old bid placement timestamp alone does not exclude a bid.

New subscriptions receive **future observations only**. Creation does not replay existing auctions or bids. Changing the destination URL, selected events, or filter behavior establishes a new activation boundary for future observations; already queued deliveries keep their captured payload. Description edits, reordering events, and changing make/source capitalization preserve that boundary. Resuming skips observations made while paused.

A historical bid first ingested after activation can still produce `bid.created`, even if its placement time is much older. Repeated polls and updates to an existing bid do not produce another event. Bid IDs identify persisted rows; upstream changes in bid identity can represent the same real-world bid with a different row ID. Use the [bid history API](/docs/auction-bids) for source coverage and the wider bid trail.

## Manage Subscriptions Through the API

All routes use `Authorization: Bearer YOUR_API_KEY`. Management remains available when your data-request quota is exhausted; normal burst limits still apply.

| Method | Route | Result |
| --- | --- | --- |
| `GET` | `/webhooks` | List your subscriptions. |
| `POST` | `/webhooks` | Create a subscription and reveal its secret once. |
| `GET` | `/webhooks/{id}` | Retrieve one subscription. |
| `PATCH` | `/webhooks/{id}` | Edit URL, description, event types, filters, or enabled state. |
| `DELETE` | `/webhooks/{id}` | Delete the subscription and its delivery history. |
| `POST` | `/webhooks/{id}/test` | Queue a test; at most one per endpoint per minute. |
| `POST` | `/webhooks/{id}/secret/rotate` | Rotate the secret; the new secret is returned in `data.secret` once. |
| `GET` | `/webhooks/{id}/deliveries` | Inspect the 20 most recent deliveries. |
| `POST` | `/webhooks/{id}/deliveries/{deliveryId}/retry` | Retry a failed delivery using its original event ID and payload. |

`PATCH` applies only the supplied top-level fields. A supplied `filters` object **replaces all existing filters**; use `"filters": {}` to clear them. For example:

```bash
curl -X PATCH https://api.oldcarsdata.com/webhooks/whe_YOUR_ID \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event_types":["bid.created"],"filters":{"make":"Porsche","source":"bringatrailer"}}'
```

Pause with `{"enabled":false}` and resume with `{"enabled":true}`. Unknown fields, duplicate or unsupported event types, invalid filters, and malformed destinations are rejected. Errors distinguish invalid input (`400`), missing/invalid authentication (`401`), account requirements (`403`), missing owned resources (`404`), and endpoint limits or test cooldown (`409`).

## Event Payload

Every event has `id`, `type`, `api_version`, `created_at`, and `data`. Version `2026-10-17` uses string IDs and integer prices/amounts. `created_at` is the UTC observation time in Old Cars Data, rather than a source-supplied timestamp. Event IDs are opaque strings.

### Auction Event

Auction events include `data.auction` with immutable captured source facts. These fields do not claim an exact historical normalized API response. `data.changes` identifies meaningful fields that changed; it is empty for a new auction.

```json
{
  "id": "evt_4d776fd1-3de0-460b-a692-4b181215f121",
  "type": "auction.updated",
  "api_version": "2026-10-17",
  "created_at": "2026-10-18T18:30:00.000Z",
  "data": {
    "auction": {
      "id": "12345",
      "source": "bringatrailer",
      "url": "https://bringatrailer.com/listing/example",
      "title": "1990 Porsche 911 Carrera Coupe",
      "status": "active",
      "price": 68000,
      "currency": "USD",
      "year": 1990,
      "make": "Porsche",
      "model": "911",
      "auction_end_at": "2026-10-19T19:00:00",
      "auction_end_date": "2026-10-19",
      "bid_count": 14,
      "sale_type": "auction",
      "price_type": null
    },
    "changes": {
      "price": { "before": 67000, "after": 68000 },
      "bid_count": { "before": 13, "after": 14 }
    }
  }
}
```

Fields may be `null` when unavailable. Auction make/model reflect captured source facts; the canonical make used for filtering may differ. `auction_end_at` is a source wall-clock timestamp **without a UTC offset**; do not assume UTC or append `Z`. `auction_end_date` supports date-only sources. Handle new source status values defensively.

### Bid Event

`bid.created` includes immutable captured bid details and a frozen parent auction ID, without bidder identity:

```json
{
  "id": "evt_bid_98765",
  "type": "bid.created",
  "api_version": "2026-10-17",
  "created_at": "2026-10-18T18:30:00.000Z",
  "data": {
    "bid": {
      "id": "98765",
      "auction_id": "12345",
      "amount": 68000,
      "currency": "USD",
      "bid_at": "2026-10-17T18:29:45"
    }
  }
}
```

`bid_at` is a source-reported wall-clock timestamp without a UTC offset. A webhook's captured parent ID is never silently reassigned if source records later change identity. An observation with unresolved parent identity remains pending. The current API can change or remove an auction after an event was captured.

### Test Event

A test uses the same envelope with `type: "webhook.test"` and `data: {"message":"Your Old Cars Data webhook is connected."}`.

## Verify Signatures

Deliveries follow [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks). Every request includes:

| Header | Value |
| --- | --- |
| `webhook-id` | The stable event ID, also present in the payload. |
| `webhook-timestamp` | Unix timestamp in seconds for this attempt. |
| `webhook-signature` | One or more space-separated `v1,BASE64_SIGNATURE` values. |

The signed bytes are `{webhook-id}.{webhook-timestamp}.{raw body}`. Secrets use a `whsec_` prefix followed by a base64-encoded signing key. Use a maintained verification library to check the raw body, signature, and timestamp tolerance:

```typescript
import { Webhook } from 'standardwebhooks'

const verifier = new Webhook(process.env.WEBHOOK_SIGNING_SECRET!)

export async function POST(request: Request) {
  const rawBody = await request.text()
  let event: unknown
  try {
    event = verifier.verify(rawBody, {
      'webhook-id': request.headers.get('webhook-id') ?? '',
      'webhook-timestamp': request.headers.get('webhook-timestamp') ?? '',
      'webhook-signature': request.headers.get('webhook-signature') ?? '',
    })
  } catch {
    return new Response('Invalid webhook signature', { status: 400 })
  }
  // Validate the event and durably enqueue it, deduplicating by event.id.
  await enqueueEvent(event)
  return new Response(null, { status: 204 })
}
```

Install `standardwebhooks` in your receiver. Verify before parsing or reserializing the body. Keep secrets on your server. After rotation, attempts carry signatures from the new and immediately previous secret for **24 hours**, allowing either key to verify during rollout. Rotation reveals the new secret once; rotating again replaces the previous-key overlap.

## Delivery and Retries

- A dedicated AWS worker continuously checks for queued deliveries; polling is normally about once per second, and backlogs can add delay. This is an observation feed, not an immediate source-time alert.
- Your endpoint has **10 seconds** to respond. Any `2xx` status succeeds; other statuses, redirects, timeouts, and network failures count as failures.
- Each delivery has up to **six attempts**: the initial attempt, then retries after approximately **1 minute, 5 minutes, 30 minutes, 2 hours, and 8 hours** following each failure.
- Delivery is **at least once** while attempts remain. Events can arrive more than once or out of order. Deduplicate by stable event ID and make downstream work safe to repeat.
- Retries preserve the original event ID and body, with a fresh timestamp and signature. A delivery can fail permanently after its retry budget is exhausted.

Return a successful response after your own durable handoff; run slow work in the background. A lost response can cause a duplicate even when your receiver processed the first request.

## Inspect and Control Deliveries

The UI's **View deliveries** and the API's deliveries route show the latest 20 deliveries, including status, attempts, latest HTTP response/error, scheduling details, and the public payload. Private capture metadata and encrypted secrets are never returned.

When a plan is downgraded or expires, excess endpoints are paused, keeping the oldest active endpoints within the new allowance. Settings and history remain available, and queued work for paused endpoints is canceled. Enforcement runs during management requests and each worker run, with entitlement checked again before sending. A request already in flight can still complete. Upgrading does not automatically resume paused endpoints. Pause a retained endpoint before resuming another when the active allowance is full. Delete saved endpoints or upgrade to free capacity for creating another endpoint.

**Retry delivery** starts a new six-attempt budget for a failed delivery. The endpoint and paid plan must be active. **Pause** cancels queued deliveries and further retries, including after resume. An HTTP request already in flight can still complete: a successful real delivery is recorded and consumes one unit; a failed attempt is canceled. **Delete** removes the subscription and its delivery history. Deletion returns HTTP 409 while a delivery attempt is active; pause the endpoint and retry deletion after the attempt finishes.

Terminal payloads and delivery history are eligible for cleanup after **30 days**. Compact source identity records remain to prevent duplicate bid events after cleanup. Pending observations and unfinished deliveries do not silently expire. Keep your own durable record when you need a longer audit trail.


## Delivery usage and monthly allowance

API and MCP requests share one monthly allowance with successful webhook deliveries:

| Plan | Monthly requests + deliveries | Webhook endpoints |
| --- | --- | --- |
| $49 / month | 1,000 | 1 |
| $199 / month | 10,000 | 10 |
| $699 / month | 100,000 | Unlimited |

One real event successfully delivered to one endpoint counts as one request. Sending the same event to two endpoints counts as two. Failed attempts, automatic retries that fail, webhook tests, and endpoint management are free. A successful retry charges once for that delivery.

When the shared allowance is exhausted, new deliveries wait without consuming retry attempts. They resume when capacity is available, including after an upgrade or the next billing period. In-flight requests temporarily reserve capacity, so a failed request can also release a slot. Pending work is retained while it waits. Unlimited endpoints on the 100K plan still share its 100,000-unit monthly allowance.

Account Overview shows usage by source, including **Webhook deliveries**. The Webhooks and Billing pages show the shared allowance and reset date. Endpoint delivery history shows queued work, attempts, the last HTTP result, and any quota deferral.
