Skip to content

Webhooks

Webhooks

Subscribe to webhooks to receive notifications when tax rates or thresholds change.

Webhook management is available through both API-key routes (/v1/webhooks) and the Clerk-authenticated dashboard routes (/users/webhooks). The examples below use /v1/webhooks; replace the prefix with /users/webhooks when calling from your signed-in dashboard session.

Create Webhook

POST /v1/webhooks
Terminal window
curl -X POST https://api.shipvat.com/v1/webhooks \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks/shipvat",
"events": ["rate.updated", "threshold.updated"],
"description": "Production webhook"
}'

Response

{
"id": "wh_abc123",
"url": "https://your-app.com/webhooks/shipvat",
"events": ["rate.updated", "threshold.updated"],
"status": "active",
"description": "Production webhook",
"secret": "whsec_abc123xyz...",
"consecutive_failures": 0,
"last_failed_at": null,
"last_succeeded_at": null,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}

List Webhooks

GET /v1/webhooks
Terminal window
curl https://api.shipvat.com/v1/webhooks \
-H "Authorization: Bearer sk_live_..."

Response

{
"webhooks": [
{
"id": "wh_abc123",
"url": "https://your-app.com/webhooks/shipvat",
"events": ["rate.updated", "threshold.updated"],
"status": "active",
"description": "Production webhook",
"consecutive_failures": 0,
"last_succeeded_at": "2024-01-15T12:00:00Z",
"created_at": "2024-01-15T10:30:00Z"
}
],
"total": 1
}

Get Webhook

GET /v1/webhooks/:id

Update Webhook

PUT /v1/webhooks/:id
Terminal window
curl -X PUT https://api.shipvat.com/v1/webhooks/wh_abc123 \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"events": ["rate.updated", "threshold.updated", "sync.completed"],
"status": "active"
}'

Delete Webhook

DELETE /v1/webhooks/:id

Rotate Secret

Generate a new webhook secret. The old secret becomes invalid immediately.

POST /v1/webhooks/:id/rotate-secret
Terminal window
curl -X POST https://api.shipvat.com/v1/webhooks/wh_abc123/rotate-secret \
-H "Authorization: Bearer sk_live_..."

Response

{
"id": "wh_abc123",
"secret": "whsec_new_secret_xyz..."
}

Delivery History

Every webhook event creates a durable delivery record with a stable event id and per-attempt history.

GET /v1/webhooks/:id/deliveries
{
"deliveries": [
{
"id": "wd_abc123",
"endpoint_id": "wh_abc123",
"event_type": "rate.updated",
"event_id": "evt_123",
"status": "pending",
"attempt_count": 1,
"max_attempts": 6,
"next_attempt_at": "2026-06-12T10:05:00.000Z",
"last_response_code": 503,
"last_error": "Webhook returned 503: Service Unavailable",
"attempts": [
{
"attempt_number": 1,
"status": "failed",
"response_code": 503,
"latency_ms": 812,
"attempted_at": "2026-06-12T10:00:00.000Z"
}
]
}
],
"total": 1
}
GET /v1/webhooks/deliveries/:deliveryId

Redeliver Event

Redelivery reuses the original X-ShipVAT-Event-Id, so your consumer can safely dedupe.

POST /v1/webhooks/deliveries/:deliveryId/redeliver

Send Test Event

Send a signed webhook.test payload to verify endpoint reachability and signature handling.

POST /v1/webhooks/:id/test

Re-enable Endpoint

After fixing an endpoint that exhausted retries, re-enable it before redelivering events.

POST /v1/webhooks/:id/re-enable

List Event Types

GET /v1/webhooks/events/list
Terminal window
curl https://api.shipvat.com/v1/webhooks/events/list \
-H "Authorization: Bearer sk_live_..."

Response

{
"events": [
{
"type": "rate.updated",
"description": "A tax rate has been updated for a jurisdiction"
},
{
"type": "rate.scheduled",
"description": "A future-effective tax rate has been detected"
},
{
"type": "threshold.updated",
"description": "A nexus threshold has been updated for a jurisdiction"
},
{
"type": "data.quarantined",
"description": "A collector sync produced a quarantined data diff for review"
},
{
"type": "freshness.degraded",
"description": "A collector region moved from healthy to degraded freshness"
},
{
"type": "jurisdiction.added",
"description": "A new jurisdiction has been added to the system"
},
{
"type": "sync.completed",
"description": "A data sync operation has completed"
}
],
"total": 7
}

Webhook Payloads

Every payload uses the same envelope. The current webhook schema version is 2026-06-12; additive fields require a new version before they are emitted.

{
"id": "evt_123",
"version": "2026-06-12",
"event": "rate.updated",
"timestamp": "2026-06-12T10:30:00.000Z",
"data": {}
}

rate.updated

{
"id": "evt_123",
"version": "2026-06-12",
"event": "rate.updated",
"timestamp": "2026-06-12T10:30:00.000Z",
"data": {
"region": "EU",
"change_count": 1,
"changes": [
{
"jurisdiction_id": "DE",
"tax_type": "VAT",
"category": "STANDARD",
"previous_rate": 18,
"new_rate": 19,
"effective_date": "2026-06-12",
"source_url": "https://europa.eu/youreurope/business/taxation/vat/vat-rules-rates/index_en.htm"
}
],
"synced_at": "2026-06-12T10:30:00.000Z",
"triggered_by": "cron"
}
}

rate.scheduled

{
"id": "evt_124",
"version": "2026-06-12",
"event": "rate.scheduled",
"timestamp": "2026-06-12T10:30:00.000Z",
"data": {
"region": "EU",
"change_count": 1,
"changes": [
{
"jurisdiction_id": "FR",
"tax_type": "VAT",
"category": "STANDARD",
"previous_rate": 20,
"new_rate": 21,
"effective_date": "2026-07-01",
"source_url": "https://example.gov/rates"
}
],
"detected_at": "2026-06-12T10:30:00.000Z",
"triggered_by": "cron"
}
}

threshold.updated

{
"id": "evt_456",
"version": "2026-06-12",
"event": "threshold.updated",
"timestamp": "2026-06-12T10:30:00.000Z",
"data": {
"region": "US",
"change_count": 1,
"changes": [
{
"jurisdiction_id": "US-CA",
"threshold_type": "REMOTE_SELLER_NEXUS",
"previous_amount": 500000,
"new_amount": 750000,
"previous_transaction_count": null,
"new_transaction_count": null,
"currency": "USD",
"condition": "AMOUNT_ONLY",
"period": "previous_calendar_year",
"effective_date": "2026-06-12",
"source_url": "https://www.salestaxinstitute.com/resources/economic-nexus-state-guide"
}
],
"synced_at": "2026-06-12T10:30:00.000Z",
"triggered_by": "cron"
}
}

data.quarantined

{
"id": "evt_555",
"version": "2026-06-12",
"event": "data.quarantined",
"timestamp": "2026-06-12T10:30:00.000Z",
"data": {
"region": "EU",
"quarantine_id": "quarantine_123",
"error": "Quarantined anomalous sync diff quarantine_123",
"failure_kind": "VALIDATION",
"log_summary": "Rate delta exceeded guard",
"quarantined_at": "2026-06-12T10:30:00.000Z",
"triggered_by": "cron"
}
}

freshness.degraded

{
"id": "evt_556",
"version": "2026-06-12",
"event": "freshness.degraded",
"timestamp": "2026-06-12T10:30:00.000Z",
"data": {
"region": "EU",
"previous_status": "HEALTHY",
"current_status": "DEGRADED",
"consecutive_failures": 2,
"last_successful_fetch": "2026-06-11T02:00:00.000Z",
"error": "Could not parse source",
"failure_kind": "PARSE",
"checked_at": "2026-06-12T10:30:00.000Z",
"triggered_by": "cron"
}
}

sync.completed

{
"id": "evt_789",
"version": "2026-06-12",
"event": "sync.completed",
"timestamp": "2026-06-12T02:00:00.000Z",
"data": {
"region": "EU",
"records_updated": 27,
"rate_changes": 1,
"threshold_changes": 0,
"duration_ms": 1234,
"synced_at": "2026-06-12T02:00:00.000Z",
"triggered_by": "cron"
}
}

Verifying Webhooks

All webhook payloads are signed using HMAC-SHA256. Verify the signature to ensure the request is from ShipVAT.

Headers

HeaderDescription
X-ShipVAT-SignatureHMAC-SHA256 signature of the raw JSON request body
X-ShipVAT-EventEvent type
X-ShipVAT-Event-IdStable event id reused across retries and manual redelivery
X-ShipVAT-Webhook-VersionPayload schema version
X-ShipVAT-TimestampEvent creation timestamp
X-ShipVAT-AttemptDelivery attempt number

Verification (Node.js)

import crypto from 'crypto';
function verifyWebhook(payload, signature, timestamp, secret) {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
// In your webhook handler
app.post('/webhooks/shipvat', (req, res) => {
const signature = req.headers['x-shipvat-signature'];
const eventId = req.headers['x-shipvat-event-id'];
const timestamp = req.headers['x-shipvat-timestamp'];
if (!verifyWebhook(req.rawBody, signature, timestamp, WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
// Store eventId and ignore duplicates in your own system.
const event = req.body;
console.log(`Received ${event.event}:`, event.data);
res.status(200).send('OK');
});

Retry Policy

  • Failed webhooks are attempted once immediately, then retried up to 5 more times.
  • Retry delays: 1min, 5min, 30min, 2hr, 6hr.
  • Retry delivery is swept by ShipVAT’s maintenance cron; attempts run as soon as the delivery is due and the Worker wakes.
  • ShipVAT uses the persisted delivery log plus scheduled sweeps instead of Cloudflare Queues for now, avoiding a new infrastructure dependency while preserving durable retry state.
  • After all attempts are exhausted, the delivery status becomes exhausted, the endpoint is marked failed, and ShipVAT emails the endpoint owner when email notifications are configured.
  • Re-enable with POST /v1/webhooks/:id/re-enable, then redeliver any exhausted deliveries from the history.