Webhooks
Receive real-time HTTP notifications when booking events occur.
Webhooks let you receive real-time HTTP POST notifications when events occur in your Astrocal account. Instead of polling the API, register a webhook endpoint and Astrocal will push events to your server as they happen.
Event Types
| Event | Trigger |
|---|---|
booking.created | A new booking is created (for paid bookings, after payment) |
booking.cancelled | An existing booking is cancelled |
booking.rescheduled | An existing booking is rescheduled |
calendar.disconnected | A calendar connection loses access and needs re-authentication |
waitlist_entry.created | Someone joins the waitlist for an event type |
waitlist_entry.cancelled | A waitlist entry is cancelled |
waitlist_entry.promoted | A waitlist entry is turned into a confirmed booking |
waitlist_entry.expired | A waitlist entry passes its expiry time without being promoted |
Creating a Webhook Endpoint
Register a URL to receive events:
curl -X POST https://api.astrocal.dev/v1/webhooks \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks/astrocal",
"events": ["booking.created", "booking.cancelled"]
}'const response = await fetch("https://api.astrocal.dev/v1/webhooks", {
method: "POST",
headers: {
Authorization: "Bearer YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://your-app.com/webhooks/astrocal",
events: ["booking.created", "booking.cancelled"],
}),
});
const data = await response.json();Try it in the API playground →
The response includes a secret field. Save this immediately. The secret is only shown once at creation time and is used to verify webhook signatures.
{
"id": "990e8400-e29b-41d4-a716-446655440000",
"url": "https://your-app.com/webhooks/astrocal",
"events": ["booking.created", "booking.cancelled"],
"secret": "whsec_abc123...",
"active": true,
"verification_status": "verified",
"verified_at": "2026-03-01T10:00:00.000Z",
"verification_last_error": null,
"consecutive_failures": 0,
"suspended_at": null,
"created_at": "2026-03-01T10:00:00.000Z",
"updated_at": "2026-03-01T10:00:00.000Z"
}A new endpoint receives no events until verification_status is verified. Creating an
endpoint always succeeds — check verification_status in the response.
Verifying Your Endpoint
Astrocal will not deliver booking data to a URL until the destination proves it wants the traffic. This stops anyone registering someone else's URL and having us send them your attendees' names, emails and meeting times.
Endpoint URLs must use https://. Booking payloads carry attendee names and email addresses, so we never send them over plaintext HTTP.
How it works
As soon as you create an endpoint, Astrocal POSTs a challenge to your URL:
POST /webhooks/astrocal HTTP/1.1
Content-Type: application/json
X-Astrocal-Event: endpoint.verification
X-Astrocal-Signature: v1=5257a869e7ecebeda32affa62cdca3fa...
{
"type": "endpoint_verification",
"challenge": "whchal_9f8a7b6c5d4e3f2a1b0c...",
"webhook_endpoint_id": "990e8400-e29b-41d4-a716-446655440000",
"created_at": "2026-03-01T10:00:00.000Z"
}Respond within 5 seconds with a 2xx status and the same challenge value. Either shape works:
{ "astrocal_challenge_ack": "whchal_9f8a7b6c5d4e3f2a1b0c..." }whchal_9f8a7b6c5d4e3f2a1b0c...The JSON key is astrocal_challenge_ack, not challenge. Our request body already contains challenge, so a service that simply echoes back whatever it receives would otherwise verify itself.
Handler example
app.post("/webhooks/astrocal", express.json(), (req, res) => {
if (req.body.type === "endpoint_verification") {
return res.json({ astrocal_challenge_ack: req.body.challenge });
}
// Normal event handling
handleEvent(req.body);
res.sendStatus(200);
});The verification POST is signed exactly like a normal delivery, so you can verify X-Astrocal-Signature before responding. See Verifying Signatures.
Retrying verification
If your server was not ready, retry once it is:
curl -X POST https://api.astrocal.dev/v1/webhooks/{id}/verify \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'{
"verification_status": "failed",
"verified_at": null,
"verification_last_error": "Endpoint returned HTTP 404"
}Verification is limited to 5 attempts per endpoint per hour, and 20 attempts per destination host per hour.
If you cannot acknowledge in the response
Some receivers — a WAF, a CDN, an off-the-shelf collector — cannot control their response body. Read the challenge from your own request log and send it back:
curl -X POST https://api.astrocal.dev/v1/webhooks/{id}/verify \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"challenge": "whchal_9f8a7b6c5d4e3f2a1b0c..."}'A challenge expires 24 hours after it is issued. Calling /verify again issues a fresh one.
When re-verification is required
| Change | Re-verification |
|---|---|
| Change the endpoint URL | Required — the endpoint returns to pending and stops receiving events |
| Change subscribed events | Not required |
Pause and resume (active) | Not required |
Verification does not expire. Once an endpoint is verified it stays verified until its URL changes.
Troubleshooting
verification_last_error | What to do |
|---|---|
Endpoint returned HTTP 404 | Check the path is deployed and accepts POST |
Challenge mismatch | Echo the challenge value exactly, with no extra whitespace or wrapping |
Response did not contain a challenge | Your handler returned 2xx but no body — return the challenge |
Request timed out | Respond within 5 seconds; do your processing after responding |
Blocked: ... | The URL is not HTTPS, or resolves to a private or reserved address; use a publicly reachable https:// host |
Payload Format
Each webhook delivery sends a JSON payload with this structure:
{
"event": "booking.created",
"data": {
"id": "770e8400-e29b-41d4-a716-446655440000",
"organization_id": "550e8400-e29b-41d4-a716-446655440000",
"event_type_id": "660e8400-e29b-41d4-a716-446655440000",
"status": "confirmed",
"start_time": "2026-03-15T14:00:00.000Z",
"end_time": "2026-03-15T14:30:00.000Z",
"invitee_name": "Jane Smith",
"invitee_email": "jane@example.com",
"invitee_timezone": "America/New_York",
"notes": null,
"created_at": "2026-03-01T10:00:00.000Z",
"updated_at": "2026-03-01T10:00:00.000Z"
},
"created_at": "2026-03-01T10:00:00.123Z"
}calendar.disconnected Payload
The calendar.disconnected event fires once when a calendar connection loses access (for example, a revoked or expired OAuth token) and needs re-authentication. Use it to automate reconnect reminders in your own tooling.
{
"event": "calendar.disconnected",
"data": {
"connection_id": "880e8400-e29b-41d4-a716-446655440000",
"provider": "google",
"account_email": "organizer@example.com",
"error": "invalid_grant",
"disconnected_at": "2026-03-01T10:00:00.000Z"
},
"is_test": false,
"created_at": "2026-03-01T10:00:00.123Z"
}The event is dispatched only on the first transition to the disconnected state — repeated sync failures while the connection is already disconnected do not re-fire it.
waitlist_entry.* Payloads
Waitlist events carry the entry, not a full booking. Every payload includes the entry id, its event_type_id, its new status, and the invitee's email.
waitlist_entry.created also carries position — the entry's place in the queue at the time it was created — and the invitee's name.
{
"event": "waitlist_entry.created",
"data": {
"id": "990e8400-e29b-41d4-a716-446655440000",
"event_type_id": "660e8400-e29b-41d4-a716-446655440000",
"status": "waiting",
"position": 3,
"invitee_name": "Jane Smith",
"invitee_email": "jane@example.com"
},
"is_test": false,
"created_at": "2026-03-01T10:00:00.123Z"
}waitlist_entry.promoted fires when a cancellation frees a slot and the entry becomes a confirmed booking. It carries booking_id, so you can fetch the new booking.
{
"event": "waitlist_entry.promoted",
"data": {
"id": "990e8400-e29b-41d4-a716-446655440000",
"event_type_id": "660e8400-e29b-41d4-a716-446655440000",
"status": "promoted",
"booking_id": "770e8400-e29b-41d4-a716-446655440000",
"invitee_email": "jane@example.com"
},
"is_test": false,
"created_at": "2026-03-01T10:00:00.123Z"
}waitlist_entry.cancelled and waitlist_entry.expired share the same shape, with status set to cancelled or expired. An entry expires when it passes its expiry time without being promoted.
{
"event": "waitlist_entry.expired",
"data": {
"id": "990e8400-e29b-41d4-a716-446655440000",
"event_type_id": "660e8400-e29b-41d4-a716-446655440000",
"status": "expired",
"invitee_email": "jane@example.com"
},
"is_test": false,
"created_at": "2026-03-01T10:00:00.123Z"
}Verifying Signatures
Every webhook delivery includes an X-Astrocal-Signature header containing an HMAC-SHA256 signature of the request body. Always verify this signature to ensure the webhook came from Astrocal and wasn't tampered with.
The header format is v1=<hex-digest>.
Node.js / TypeScript Example
import crypto from "node:crypto";
function verifyWebhookSignature(payload: string, signature: string, secret: string): boolean {
const expectedSig = crypto.createHmac("sha256", secret).update(payload).digest("hex");
const expected = `v1=${expectedSig}`;
// Use timing-safe comparison to prevent timing attacks
if (signature.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
// In your webhook handler:
app.post("/webhooks/astrocal", (req, res) => {
const signature = req.headers["x-astrocal-signature"];
const rawBody = req.body; // Must be the raw string, not parsed JSON
if (!verifyWebhookSignature(rawBody, signature, WEBHOOK_SECRET)) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(rawBody);
// Handle the event...
res.status(200).send("OK");
});Headers
Each webhook delivery includes these headers:
| Header | Description |
|---|---|
Content-Type | Always application/json |
X-Astrocal-Signature | HMAC-SHA256 signature (v1=<hex>) |
X-Astrocal-Event | The event type (e.g. booking.created) |
Retry Behavior
If your endpoint returns a non-2xx status code or the request times out (10 seconds), Astrocal will retry with exponential backoff:
| Attempt | Delay After |
|---|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 24 hours |
Each delay carries up to 20% random jitter, so deliveries queued together do not retry in lockstep. The table shows the base delay.
After 6 failed attempts, the delivery is marked as permanently failed. You can view delivery history via the API.
Suspension
Astrocal suspends an endpoint after 5 consecutive failed attempts. A suspended endpoint receives no events, and its queued deliveries are marked failed straight away. Any successful delivery resets the counter to zero.
Suspension shows up as a suspended_at timestamp on the endpoint. consecutive_failures tells you how close a healthy endpoint is to the limit.
Recovery is manual. Fix the destination first, then resume the endpoint — resuming a still-broken endpoint just suspends it again.
curl -X POST https://api.astrocal.dev/v1/webhooks/WEBHOOK_ID/resume \
-H "Authorization: Bearer YOUR_API_KEY"const response = await fetch(
"https://api.astrocal.dev/v1/webhooks/WEBHOOK_ID/resume",
{
method: "POST",
headers: { Authorization: "Bearer YOUR_API_KEY" },
}
);
const webhook = await response.json();Resuming does not re-verify the URL, and verifying does not resume a suspended endpoint. The two states are independent.
You can also resume from Dashboard > Webhooks with the Resume button on the suspended endpoint.
Limits
| Limit | Value | What happens at the limit |
|---|---|---|
| Endpoints per organization | 50 | POST /v1/webhooks returns 409 Conflict |
| Pending deliveries per org | 500 | New events are not queued until the backlog clears |
| Consecutive failures per endpoint | 5 | The endpoint is suspended |
These are platform limits and are the same on every plan.
Managing Webhooks
You can manage webhooks from the dashboard or via the API.
Dashboard
The Webhooks page in the developer dashboard lets you:
- Create, edit, and delete webhook endpoints
- Toggle endpoints active/inactive
- Resume an endpoint suspended after repeated delivery failures
- View delivery logs with status, HTTP response codes, and retry counts
- Copy the signing secret on creation
Navigate to Dashboard > Webhooks to get started.
List Endpoints
curl https://api.astrocal.dev/v1/webhooks \
-H "Authorization: Bearer YOUR_API_KEY"const response = await fetch("https://api.astrocal.dev/v1/webhooks", {
headers: {
Authorization: "Bearer YOUR_API_KEY",
},
});
const data = await response.json();Try it in the API playground →
Update an Endpoint
curl -X PATCH https://api.astrocal.dev/v1/webhooks/WEBHOOK_ID \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"active": false}'const response = await fetch(
"https://api.astrocal.dev/v1/webhooks/WEBHOOK_ID",
{
method: "PATCH",
headers: {
Authorization: "Bearer YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({ active: false }),
}
);
const data = await response.json();Try it in the API playground →
Delete an Endpoint
curl -X DELETE https://api.astrocal.dev/v1/webhooks/WEBHOOK_ID \
-H "Authorization: Bearer YOUR_API_KEY"const response = await fetch(
"https://api.astrocal.dev/v1/webhooks/WEBHOOK_ID",
{
method: "DELETE",
headers: {
Authorization: "Bearer YOUR_API_KEY",
},
}
);Try it in the API playground →
View Delivery History
curl "https://api.astrocal.dev/v1/webhooks/WEBHOOK_ID/deliveries?status=failed" \
-H "Authorization: Bearer YOUR_API_KEY"const response = await fetch(
"https://api.astrocal.dev/v1/webhooks/WEBHOOK_ID/deliveries?status=failed",
{
headers: {
Authorization: "Bearer YOUR_API_KEY",
},
}
);
const data = await response.json();Try it in the API playground →
Best Practices
- Always verify signatures. Never trust webhook payloads without signature verification.
- Respond quickly. Return a 2xx response within 10 seconds. Process the event asynchronously if needed.
- Handle duplicates. In rare cases, the same event may be delivered more than once. Use the event
data.idfor idempotency. - Use HTTPS. Always use HTTPS URLs for your webhook endpoints in production.
- Monitor deliveries. Check the dashboard or the deliveries endpoint for failed deliveries.