Astrocal
Guides

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

EventTrigger
booking.createdA new booking is created (for paid bookings, after payment)
booking.cancelledAn existing booking is cancelled
booking.rescheduledAn existing booking is rescheduled
calendar.disconnectedA calendar connection loses access and needs re-authentication
waitlist_entry.createdSomeone joins the waitlist for an event type
waitlist_entry.cancelledA waitlist entry is cancelled
waitlist_entry.promotedA waitlist entry is turned into a confirmed booking
waitlist_entry.expiredA 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

ChangeRe-verification
Change the endpoint URLRequired — the endpoint returns to pending and stops receiving events
Change subscribed eventsNot 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_errorWhat to do
Endpoint returned HTTP 404Check the path is deployed and accepts POST
Challenge mismatchEcho the challenge value exactly, with no extra whitespace or wrapping
Response did not contain a challengeYour handler returned 2xx but no body — return the challenge
Request timed outRespond 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:

HeaderDescription
Content-TypeAlways application/json
X-Astrocal-SignatureHMAC-SHA256 signature (v1=<hex>)
X-Astrocal-EventThe 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:

AttemptDelay After
1Immediate
21 minute
35 minutes
430 minutes
52 hours
624 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

LimitValueWhat happens at the limit
Endpoints per organization50POST /v1/webhooks returns 409 Conflict
Pending deliveries per org500New events are not queued until the backlog clears
Consecutive failures per endpoint5The 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

  1. Always verify signatures. Never trust webhook payloads without signature verification.
  2. Respond quickly. Return a 2xx response within 10 seconds. Process the event asynchronously if needed.
  3. Handle duplicates. In rare cases, the same event may be delivered more than once. Use the event data.id for idempotency.
  4. Use HTTPS. Always use HTTPS URLs for your webhook endpoints in production.
  5. Monitor deliveries. Check the dashboard or the deliveries endpoint for failed deliveries.

On this page