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/webhookscurl -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/webhookscurl 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/:idUpdate Webhook
PUT /v1/webhooks/:idcurl -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/:idRotate Secret
Generate a new webhook secret. The old secret becomes invalid immediately.
POST /v1/webhooks/:id/rotate-secretcurl -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/:deliveryIdRedeliver Event
Redelivery reuses the original X-ShipVAT-Event-Id, so your consumer can safely dedupe.
POST /v1/webhooks/deliveries/:deliveryId/redeliverSend Test Event
Send a signed webhook.test payload to verify endpoint reachability and signature handling.
POST /v1/webhooks/:id/testRe-enable Endpoint
After fixing an endpoint that exhausted retries, re-enable it before redelivering events.
POST /v1/webhooks/:id/re-enableList Event Types
GET /v1/webhooks/events/listcurl 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
| Header | Description |
|---|---|
X-ShipVAT-Signature | HMAC-SHA256 signature of the raw JSON request body |
X-ShipVAT-Event | Event type |
X-ShipVAT-Event-Id | Stable event id reused across retries and manual redelivery |
X-ShipVAT-Webhook-Version | Payload schema version |
X-ShipVAT-Timestamp | Event creation timestamp |
X-ShipVAT-Attempt | Delivery 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 handlerapp.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 markedfailed, 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.