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 POST requests with a signed JWT body (application/jwt). The verification handshake uses JSON.
  • Respond with a 2xx status 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:

FilterValuesDescription
resource_typeSee event table belowOnly receive notifications for this resource type.
namespaceingestion, batch_stats, cadastral_lookup, parcel_ingest, assessment, artifacts, eudr, discovery, profiling, billing, summariesOnly 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:

NamespaceEventDescription
ingestionLocations validatedFired after location validation completes. Includes featureCount.
ingestionLocations stagedFired when locations are staged without batch processing.
ingestionPipeline failedFired when the ingestion pipeline encounters an error.
batch_statsProcessing completedFired when all batch statistics have finished processing.
batch_statsProcessing failedFired 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 typeNamespaceEvents
dds_record_taskeudreudr.task.completed, eudr.task.failed
dds_record_versioneudreudr.traces.reference_issued, eudr.traces.rejected, eudr.traces.withdrawn, eudr.traces.timed_out
attachmenteudreudr.attachment.stored
geocode_confidence_taskdiscoverydiscovery.validation.completed, discovery.validation.failed, discovery.validation.expired
source_discovery_taskdiscoverydiscovery.source.generation_ready, discovery.source.completed, discovery.source.failed, discovery.source.expired
profile_units_taskprofilingprofiling.units.ready, profiling.units.failed, profiling.units.expired
checkout_sessionbillingbilling.checkout.paid, billing.checkout.expired, billing.checkout.refunded
org_subscriptionbillingbilling.subscription.updated
artifactartifactsExisting completion/failure payloads, plus artifact.cancelled
assessment_taskassessmentassessment.task.completed, assessment.task.partial, assessment.task.failed
organizationsummariessupplier.summary.updated, location.summary.updated (batch invalidations with supplier/location IDs)
dds_record_versionsummariesdds.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

HeaderDescription
Content-Typeapplication/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

ClaimDescription
subThe webhook subscription ID.
issAlways epoch-fm-api.
iatIssued-at timestamp (Unix seconds).
expExpiration timestamp — 5 minutes after iat.

Verification steps

  1. Read the raw request body as a UTF-8 JWT string.
  2. Verify the JWT using your subscription's signing_secret with the HS256 algorithm.
  3. Check the iss claim is epoch-fm-api.
  4. Check the sub claim matches the expected subscription ID.
  5. Check the exp claim 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:

AttemptDelay
1Immediate
25 seconds
310 seconds
420 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:

FieldDescription
statuspending, delivered, or failed.
attemptsNumber of delivery attempts so far.
requestThe request headers and body sent to your URL.
responseThe response status, headers, body (truncated to 4 KB), and response time in milliseconds.
errorError 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
  • 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 exp claim.

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 iss claim — confirm the issuer is epoch-fm-api to reject tokens from other sources.
  • Reject expired tokens — enforce the exp claim 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

ActionMethodPath
CreatePOST/webhooks
List allGET/webhooks
Get oneGET/webhooks/:id
UpdatePATCH/webhooks/:id
DeleteDELETE/webhooks/:id
Verify URLPOST/webhooks/:id/verify
Rotate secretPOST/webhooks/:id/rotate-secret
List deliveriesGET/webhooks/:id/deliveries
Get deliveryGET/webhooks/:id/deliveries/:deliveryId

Best practices

  • Respond quickly — return a 200 within 10 seconds. Do heavy processing asynchronously.
  • Handle duplicates — use the notification id to deduplicate, as retries may deliver the same notification more than once.
  • Monitor delivery logs — check GET /webhooks/:id/deliveries periodically to catch failures early.
  • Use filters — scope subscriptions to specific resource_type and namespace values to reduce noise.