Webhooks
Webhooks allow your application to receive real-time HTTP notifications when events occur in the API. Instead of polling for changes, you register a URL and the API pushes notifications to it as they happen.
▶Overview
The webhook system has three main concepts:
- Notifications — records of events that happen in your organization (e.g. ingestion validated, batch completed). You can list and read these via
GET /notifications. - Webhook subscriptions — URLs you register to receive notifications as HTTP POST requests. Manage these via
/webhooks. - Delivery logs — a record of every HTTP request sent to your webhook URL, including request/response details and retry status. View these via
GET /webhooks/:id/deliveries.
▶Setup guide
▶1. Create a webhook subscription
curl -X POST https://api.epoch.blue/webhooks \
-H "x-api-key: sk-epoch-..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhook",
"resource_type": "asset_task",
"namespace": "ingestion"
}'
The response includes a signing_secret in plain text. Store this securely — it is only shown in full on creation and when rotated. All other API responses mask the secret (e.g. whsec_...ab12). You will use it to verify incoming webhook signatures.
▶2. Implement your webhook endpoint
Your endpoint must:
- Accept
POSTrequests with a signed JWT body (application/jwt). The verification handshake uses JSON. - Respond with a
2xxstatus code within 10 seconds. - Handle the verification challenge (see below).
▶3. Verify the webhook URL
Before the subscription receives real notifications, you must verify ownership of the URL:
curl -X POST https://api.epoch.blue/webhooks/{id}/verify \
-H "x-api-key: sk-epoch-..."
The API sends a POST to your URL with a challenge token:
{
"type": "webhook.verification",
"challenge": "ch_a1b2c3d4e5f6g7h8i9j0"
}
Your endpoint must respond with 200 OK and echo the challenge:
{
"challenge": "ch_a1b2c3d4e5f6g7h8i9j0"
}
The verification token expires after 30 minutes. If it expires, call the verify endpoint again to generate a new challenge.
Once verified, the subscription is active and will start receiving notifications.
▶Filtering
You can scope a subscription to specific notification types using resource_type and namespace:
| Filter | Values | Description |
|---|---|---|
resource_type | See event table below | Only receive notifications for this resource type. |
namespace | ingestion, batch_stats, cadastral_lookup, parcel_ingest, assessment, artifacts, eudr, discovery, profiling, billing, summaries | Only receive notifications in this namespace. |
If both are null, the subscription receives all notifications for the organization.
Subscriptions belong to one organization. A parent organization's read access to child data does not make its webhook receive child events; create a subscription in the owning child organization when needed.
You can update filters on an existing subscription via PATCH /webhooks/:id. Changing the url resets verification status — you must verify the new URL before deliveries resume.
▶Notification events
The following notifications are emitted by the API:
| Namespace | Event | Description |
|---|---|---|
ingestion | Locations validated | Fired after location validation completes. Includes featureCount. |
ingestion | Locations staged | Fired when locations are staged without batch processing. |
ingestion | Pipeline failed | Fired when the ingestion pipeline encounters an error. |
batch_stats | Processing completed | Fired when all batch statistics have finished processing. |
batch_stats | Processing failed | Fired when batch statistics processing fails. |
Additional status notifications use notification.data.event as a machine-readable event name. Filter subscriptions with the resource type and namespace, then branch on data.event in the receiver.
| Resource type | Namespace | Events |
|---|---|---|
dds_record_task | eudr | eudr.task.completed, eudr.task.failed |
dds_record_version | eudr | eudr.traces.reference_issued, eudr.traces.rejected, eudr.traces.withdrawn, eudr.traces.timed_out |
attachment | eudr | eudr.attachment.stored |
geocode_confidence_task | discovery | discovery.validation.completed, discovery.validation.failed, discovery.validation.expired |
source_discovery_task | discovery | discovery.source.generation_ready, discovery.source.completed, discovery.source.failed, discovery.source.expired |
profile_units_task | profiling | profiling.units.ready, profiling.units.failed, profiling.units.expired |
checkout_session | billing | billing.checkout.paid, billing.checkout.expired, billing.checkout.refunded |
org_subscription | billing | billing.subscription.updated |
artifact | artifacts | Existing completion/failure payloads, plus artifact.cancelled |
assessment_task | assessment | assessment.task.completed, assessment.task.partial, assessment.task.failed |
organization | summaries | supplier.summary.updated, location.summary.updated (batch invalidations with supplier/location IDs) |
dds_record_version | summaries | dds.preflight.updated |
DDS task payloads include operation, status, task_id, record_id, result, and read links. Operations cover record creation, version creation, submission, validation and report rendering. A grouped create finishes when its aggregate task finishes; partial: true identifies partial success. Submission completion and later TRACES reference issuance are separate events, as is readiness of a report re-rendered after references arrive.
Discovery and profile completion are observed by a server-side sweep, independent of customer GET requests. The scheduler runs every minute; notification latency also depends on upstream completion and sweep backlog. A discovery generation event links to partial results; terminal events link to the final status/results. profiling.units.ready means profile the same file again to retrieve refined units; it does not mean new locations were ingested. Expiry events distinguish upstream disappearance or refinement timeout from successful completion.
Checkout paid follows credit application, and refunded follows credit reversal. Identifiers and links are scoped to the owning organization. Attachment stored follows successful storage confirmation; an upload that never confirms remains pending and produces no stored event. Existing ingestion staging/metrics and cadastral notifications continue to use their established payloads.
Summary notifications invalidate cached reads after refresh. They are not a claim that every summary in the organization is ready; use the supplied IDs and fetch the relevant resource. A supplier drain whose affected IDs are not returned emits scope: "organization"; refetch the supplier list for that organization. Ordinary browser/MCP clients still need a receiving service before they can replace polling with webhooks.
Parcel-ingest preparation failures emit parcel.ingest.failed under resource type asset_task, namespace parcel_ingest. Once preparation delegates to the ingestion pipeline, that pipeline retains its existing ingestion events.
▶Webhook payload
When a notification is created, the API sends an HTTP POST to each matching verified subscription. The request includes:
▶Headers
| Header | Description |
|---|---|
Content-Type | application/jwt |
▶Body
The body is the compact JWT string, signed with HS256. After signature verification its claims include:
{
"notification": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"message": "Locations validated: 150 features",
"resource_id": "d4e5f6a7-b8c9-0123-4567-890abcdef012",
"resource_type": "asset_task",
"namespace": "ingestion",
"data": { "featureCount": 150 }
},
"sub": "webhook-subscription-id",
"iss": "epoch-fm-api",
"iat": 1773397800,
"exp": 1773398100
}
▶Verifying webhook signatures
Every webhook delivery body is a JWT signed with the subscription's HMAC secret (HS256 algorithm). Verify the body before reading its notification claim.
▶JWT claims
| Claim | Description |
|---|---|
sub | The webhook subscription ID. |
iss | Always epoch-fm-api. |
iat | Issued-at timestamp (Unix seconds). |
exp | Expiration timestamp — 5 minutes after iat. |
▶Verification steps
- Read the raw request body as a UTF-8 JWT string.
- Verify the JWT using your subscription's
signing_secretwith the HS256 algorithm. - Check the
issclaim isepoch-fm-api. - Check the
subclaim matches the expected subscription ID. - Check the
expclaim has not passed to reject expired tokens.
▶Example (Node.js with jose)
import { jwtVerify } from "jose";
async function verifyWebhook(request, signingSecret, subscriptionId) {
const token = await request.text();
const secret = new TextEncoder().encode(signingSecret);
const { payload } = await jwtVerify(token, secret, {
algorithms: ["HS256"],
issuer: "epoch-fm-api",
subject: subscriptionId,
});
// payload.sub contains the webhook subscription ID
// payload.iat contains the issued-at timestamp
return payload;
}
▶Example (Python with PyJWT)
import jwt
def verify_webhook(request, signing_secret, subscription_id):
token = request.get_data(as_text=True)
payload = jwt.decode(
token,
signing_secret,
algorithms=["HS256"],
issuer="epoch-fm-api",
subject=subscription_id,
)
# payload["sub"] contains the webhook subscription ID
return payload
▶Retries and failure handling
If your endpoint does not respond with a 2xx status code (or times out), the API retries the delivery with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 5 seconds |
| 3 | 10 seconds |
| 4 | 20 seconds |
There are up to four inline attempts. Each attempt creates a delivery log; the log's next_attempt_at metadata does not imply that a durable retry worker will deliver it later. Inspect GET /webhooks/:id/deliveries for request, response and errors. Delivery remains best effort: keep status GETs as a recovery option after outages or worker termination.
Only active, verified subscriptions are selected for fanout. Receivers should deduplicate by notification.id; new status producers also use a scoped notification_key receipt to suppress repeated observations while that notification record exists. Concurrent emitters lease the receipt while delivering; an abandoned claim can be recovered after five minutes. This receipt does not make state changes and event creation atomic.
▶Delivery logs
Each delivery attempt is recorded with full request and response details:
| Field | Description |
|---|---|
status | pending, delivered, or failed. |
attempts | Number of delivery attempts so far. |
request | The request headers and body sent to your URL. |
response | The response status, headers, body (truncated to 4 KB), and response time in milliseconds. |
error | Error message if the delivery failed (e.g. timeout, connection refused). |
▶Retention
The API retains the last 100 delivery logs per subscription. Older entries are automatically pruned when new deliveries are created.
▶Pagination
Delivery logs are paginated with a default page size of 50 and a maximum of 100 per page.
▶Secret rotation
If your signing secret is compromised, rotate it immediately:
curl -X POST https://api.epoch.blue/webhooks/{id}/rotate-secret \
-H "x-api-key: sk-epoch-..."
The old secret is invalidated immediately — there is no grace period. The response contains the new secret in plain text. Update your endpoint's verification logic with the new secret before processing any further deliveries.
▶Security
▶SSRF prevention
Webhook URLs are validated to prevent server-side request forgery (SSRF):
- URLs must use HTTPS. HTTP is only permitted in development environments.
- Hostnames are resolved and checked against blocked IP ranges before any request is made:
- Loopback:
127.0.0.0/8,::1 - Private networks:
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 - Link-local:
169.254.0.0/16,fe80::/10 - Unique local:
fc00::/7
- Loopback:
- The resolved IP is pinned for the request to prevent DNS rebinding attacks.
▶Signing secrets
- Secrets are 32-byte cryptographically random values (256 bits of entropy).
- Secrets are only returned in plain text on creation (
POST /webhooks) and rotation (POST /webhooks/:id/rotate-secret). All other responses mask the secret. - JWTs expire after 5 minutes — reject any token past its
expclaim.
▶Verification handshake
- The challenge token is validated using a timing-safe comparison to prevent timing attacks.
- Verification tokens expire after 30 minutes. Expired tokens cannot be used to verify a subscription.
- Unverified subscriptions do not receive any deliveries.
▶Recommendations
- Always verify signatures — never trust a webhook payload without verifying the JWT request body.
- Check the
issclaim — confirm the issuer isepoch-fm-apito reject tokens from other sources. - Reject expired tokens — enforce the
expclaim to prevent replay attacks. - Rotate secrets if you suspect a compromise. The old secret is invalidated immediately.
- Use HTTPS for your webhook endpoint to protect payloads in transit.
▶Managing subscriptions
| Action | Method | Path |
|---|---|---|
| Create | POST | /webhooks |
| List all | GET | /webhooks |
| Get one | GET | /webhooks/:id |
| Update | PATCH | /webhooks/:id |
| Delete | DELETE | /webhooks/:id |
| Verify URL | POST | /webhooks/:id/verify |
| Rotate secret | POST | /webhooks/:id/rotate-secret |
| List deliveries | GET | /webhooks/:id/deliveries |
| Get delivery | GET | /webhooks/:id/deliveries/:deliveryId |
▶Best practices
- Respond quickly — return a
200within 10 seconds. Do heavy processing asynchronously. - Handle duplicates — use the notification
idto deduplicate, as retries may deliver the same notification more than once. - Monitor delivery logs — check
GET /webhooks/:id/deliveriesperiodically to catch failures early. - Use filters — scope subscriptions to specific
resource_typeandnamespacevalues to reduce noise.