Webhooks
Stop polling. Subscribe once and we POST a signed payload to your endpoint when each event fires — sunrise, sunset, civil dawn / dusk, solar noon, moonrise / moonset.
Endpoints (Pro+ tier):
POST /v1/{product}/webhooks/— registerGET /v1/{product}/webhooks/— list mineGET /v1/{product}/webhooks/<uuid>/— retrieveDELETE /v1/{product}/webhooks/<uuid>/— deactivate
{product} is one of ephemerides
or solar.
Same contract for both.
Quickstart
1. Register a subscription
curl -X POST \
-H "Authorization: Bearer $THIRDTRAIL_KEY" \
-H "Content-Type: application/json" \
-d '{
"event_type": "sunrise",
"lat": 53.349, "lon": -6.260, "elevation": 20,
"callback_url": "https://yourapp.example.com/wh/sunrise",
"horizon_days": 30
}' \
"https://thirdtrail.life/v1/ephemerides/webhooks/"
Response (201 Created):
{
"id": "8a4c6f12-9b5e-4ad7-8c19-1e7f4a2b5d7c",
"event_type": "sunrise",
"lat": 53.349, "lon": -6.260, "elevation_m": 20,
"horizon_days": 30,
"callback_url": "https://yourapp.example.com/wh/sunrise",
"is_active": true,
"created_at": "2026-05-19T11:24:30+00:00",
"last_delivered_at": null,
"secret": "Hd7Lo_ec1ddNTJpA6_3a9cYJnXzQK6w7Ld9iqJqg7vQ"
}
secret is shown exactly once.
Store it immediately; we do not return it on subsequent reads.
No rotation endpoint in v1 — to rotate, delete the webhook and
register a new one.
2. Receive a delivery
When the next event fires, we POST to your callback_url:
POST /wh/sunrise HTTP/1.1
Host: yourapp.example.com
Content-Type: application/json
User-Agent: thirdtrail-webhook/1.0
X-Thirdtrail-Signature: sha256=2c8b3e0fa7d4...
X-Thirdtrail-Event: sunrise
X-Thirdtrail-Delivery: f12a7b30-2cd1-4eaa-9b1c-8d9e0f3a4c5d
{
"event_id": "f12a7b30-2cd1-4eaa-9b1c-8d9e0f3a4c5d",
"webhook_id": "8a4c6f12-9b5e-4ad7-8c19-1e7f4a2b5d7c",
"event_type": "sunrise",
"scheduled_at": "2026-05-20T05:33:18+00:00",
"attempt": 1,
"data": {
"lat": 53.349, "lon": -6.260, "elevation_m": 20
}
}
3. Verify the signature
import hmac, hashlib
def verify(secret: str, body: bytes, header: str) -> bool:
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
sent = header.removeprefix("sha256=")
return hmac.compare_digest(expected, sent)
Use hmac.compare_digest (constant-time comparison)
to mitigate timing attacks. Reject any request whose signature
does not match.
4. Be idempotent
We retry failed deliveries. If your service was actually able
to process attempt 1 but the response was lost, you may receive
attempt 2 for the same event. Use
X-Thirdtrail-Delivery as your idempotency key
— store it on first success, treat repeats as no-ops.
Event catalogue
event_type | Product | Description |
|---|---|---|
sunrise | Ephemerides | Sun's apparent disc clears the elevation-corrected horizon. |
sunset | Ephemerides | Sun's apparent disc drops below the elevation-corrected horizon. |
civil_dawn | Ephemerides | Sun crosses −6° altitude going up. |
civil_dusk | Ephemerides | Sun crosses −6° altitude going down. |
solar_noon | Ephemerides + Solar | Sun reaches its daily meridian transit. |
moonrise | Ephemerides | Moon's apparent disc clears the horizon. |
moonset | Ephemerides | Moon's apparent disc drops below the horizon. |
Delivery contract
Headers we send
| Header | Purpose |
|---|---|
Content-Type | Always application/json. |
User-Agent | thirdtrail-webhook/<version>. |
X-Thirdtrail-Signature | sha256=<hex> — HMAC-SHA256(webhook.secret, body). |
X-Thirdtrail-Event | The event_type, mirrored for log / route convenience. |
X-Thirdtrail-Delivery | UUID, unique per attempt. Use as your idempotency key. |
Timeouts and retries
- Request timeout: 10 seconds.
- Success: any 2xx status code. Webhook's
last_delivered_atis updated, no retry queued. - Caller opt-out:
410 Gonemeans "you have removed this endpoint" — we do not retry that delivery (the subscription itself stays active). - Other failures (5xx, network errors, non-2xx-non-410): exponential-backoff retry on the schedule below.
| Attempt | Delay before retry |
|---|---|
| 1 fails | 1 minute |
| 2 fails | 5 minutes |
| 3 fails | 30 minutes |
| 4 fails | 2 hours |
| 5 fails | give up — webhook is deactivated; no further deliveries fire. |
When a webhook auto-deactivates, its is_active flag
flips to false and deactivated_at is
set. The subscription will not be resurrected without a manual
re-POST.
What you should return
- 2xx: with an empty body or any body. We don't read it.
- 4xx (other than 410): we retry on the schedule above.
- 410 Gone: stop retrying that delivery; subscription stays active.
- Any 3xx redirect: we do not follow redirects. Use a stable URL.
Security
- The
secretis the only credential needed to verify our callbacks. Treat it like any production secret — do not log it, do not commit it to source control. - Rotation: there is no
PATCHendpoint to roll the secret. Delete the existing webhook and register a new one. - We use TLS for all delivery POSTs by default. If you accept HTTP callback URLs, please don't — they leak both your signature and any user identifiers in the body.
Limits
| Plan | Webhooks per account | Deliveries / day |
|---|---|---|
| Free | not enabled | — |
| Starter | not enabled | — |
| Pro | 100 | ~3 000 |
| Business | 10 000 | ~300 000 |
| Enterprise | custom | custom |