ScheduCal Webhooks allow your application to receive real-time notifications when calendar events change. When an attendee responds to a meeting invitation (accepts, declines, or proposes a new time), ScheduCal will send an HTTP POST request to your registered webhook URL.
curl -X POST "https://api.scheducal.com/api/v1/webhooks" \
-H "Content-Type: application/json" \
-d '{
"apiKey": "your-api-key",
"apiSecret": "your-api-secret",
"webhookUrl": "https://your-app.com/webhooks/scheducal",
"eventTypes": ["attendee.responded", "appointment.updated"]
}'
Response:
{
"success": true,
"data": {
"webhookId": "ced4cc09-eec1-409c-9146-379d8e75c9cd",
"webhookUrl": "https://your-app.com/webhooks/scheducal",
"secret": "aujHqc8fuw/dBx6quWO8d92hlHGsrsuOAXXmx2YFDc0=",
"eventTypes": ["attendee.responded", "appointment.updated"],
"isActive": true,
"dateCreated": "2026-01-02T02:40:00Z"
}
}
Important: Save the secret value. You’ll need it to verify webhook signatures.
Your endpoint must:
When an attendee responds to a calendar invitation, you’ll receive:
POST /webhooks/scheducal HTTP/1.1
Host: your-app.com
Content-Type: application/json
X-ScheduCal-Signature: sha256=aujHqc8fuw/dBx6quWO8d92hlHGsrsuOAXXmx2YFDc0=
X-ScheduCal-Timestamp: 1735789200
X-ScheduCal-Event: attendee.responded
X-ScheduCal-Delivery: 550e8400-e29b-41d4-a716-446655440000
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"eventType": "attendee.responded",
"timestamp": "2026-01-02T10:00:00Z",
"apiVersion": "v1",
"data": {
"appointmentId": "AAMkADI3YjRk...",
"subject": "Project Planning Meeting",
"start": "2026-01-10T14:00:00",
"end": "2026-01-10T15:00:00",
"timeZone": "America/Los_Angeles",
"attendee": {
"email": "john.doe@example.com",
"name": "John Doe",
"response": "accepted",
"respondedAt": "2026-01-02T09:45:00Z"
}
}
}
| Event Type | Description |
|---|---|
attendee.responded |
An attendee accepted, declined, or tentatively accepted the invitation |
attendee.proposed_new_time |
An attendee proposed an alternative meeting time |
appointment.updated |
The appointment’s time, location, or subject was changed |
appointment.canceled |
The appointment was canceled |
POST /api/v1/webhooks
Register a new webhook endpoint for your account.
Request Body:
{
"apiKey": "string (required)",
"apiSecret": "string (required)",
"webhookUrl": "string (required) - HTTPS URL",
"eventTypes": ["string"] (required) - Array of event types
}
Response: 201 Created
{
"success": true,
"data": {
"webhookId": "uuid",
"webhookUrl": "string",
"secret": "string - Save this for signature verification",
"eventTypes": ["string"],
"isActive": true,
"dateCreated": "ISO 8601 timestamp"
}
}
GET /api/v1/webhooks
List all webhooks registered for your account.
Query Parameters:
apiKey (required)apiSecret (required)Or use headers:
X-Api-KeyX-Api-SecretResponse: 200 OK
{
"success": true,
"data": [
{
"webhookId": "uuid",
"webhookUrl": "string",
"eventTypes": ["string"],
"isActive": true,
"consecutiveFailures": 0,
"lastDeliveryAt": "ISO 8601 timestamp or null",
"lastDeliveryStatus": "success | failed | null",
"dateCreated": "ISO 8601 timestamp"
}
]
}
PATCH /api/v1/webhooks/{webhookId}
Update an existing webhook’s configuration.
Request Body:
{
"apiKey": "string (required)",
"apiSecret": "string (required)",
"webhookUrl": "string (optional)",
"eventTypes": ["string"] (optional),
"isActive": true | false (optional)
}
Response: 200 OK
DELETE /api/v1/webhooks/{webhookId}
Delete a webhook.
Query Parameters:
apiKey (required)apiSecret (required)Response: 200 OK
{
"success": true,
"message": "Webhook deleted successfully"
}
POST /api/v1/webhooks/{webhookId}/regenerate-secret
Generate a new signing secret. The old secret will immediately stop working.
Request Body:
{
"apiKey": "string (required)",
"apiSecret": "string (required)"
}
Response: 200 OK
{
"success": true,
"data": {
"webhookId": "uuid",
"secret": "new-secret-value"
}
}
All webhook deliveries follow this structure:
{
"id": "Unique delivery ID (UUID)",
"eventType": "Event type string",
"timestamp": "ISO 8601 timestamp when event occurred",
"apiVersion": "v1",
"data": {
"appointmentId": "ScheduCal appointment ID",
"subject": "Meeting subject",
"start": "Start time (ISO 8601)",
"end": "End time (ISO 8601)",
"timeZone": "IANA timezone",
"attendee": {
"email": "Attendee email",
"name": "Attendee name (if available)",
"response": "accepted | declined | tentative",
"respondedAt": "When they responded (ISO 8601)",
"proposedStart": "Proposed new start (if applicable)",
"proposedEnd": "Proposed new end (if applicable)"
}
}
}
| Header | Description |
|---|---|
X-ScheduCal-Signature |
HMAC-SHA256 signature for verification |
X-ScheduCal-Timestamp |
Unix timestamp when the webhook was sent |
X-ScheduCal-Event |
The event type |
X-ScheduCal-Delivery |
Unique ID for this delivery attempt |
Content-Type |
Always application/json |
To ensure webhooks are genuinely from ScheduCal, verify the signature:
X-ScheduCal-Timestamp header{timestamp}.{body}X-ScheduCal-Signature (after removing sha256= prefix)const crypto = require('crypto');
function verifySignature(secret, timestamp, body, signature) {
const signedPayload = `${timestamp}.${body}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('base64');
const receivedSignature = signature.replace('sha256=', '');
return crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(receivedSignature)
);
}
// Express.js middleware
app.post('/webhooks/scheducal', express.raw({type: 'application/json'}), (req, res) => {
const signature = req.headers['x-schedcal-signature'];
const timestamp = req.headers['x-schedcal-timestamp'];
const body = req.body.toString();
if (!verifySignature(WEBHOOK_SECRET, timestamp, body, signature)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(body);
// Process the event...
console.log(`Received ${event.eventType} for appointment ${event.data.appointmentId}`);
res.status(200).send('OK');
});
import hmac
import hashlib
import base64
from flask import Flask, request
app = Flask(__name__)
WEBHOOK_SECRET = 'your-webhook-secret'
def verify_signature(secret, timestamp, body, signature):
signed_payload = f"{timestamp}.{body}"
expected = base64.b64encode(
hmac.new(
secret.encode(),
signed_payload.encode(),
hashlib.sha256
).digest()
).decode()
received = signature.replace('sha256=', '')
return hmac.compare_digest(expected, received)
@app.route('/webhooks/scheducal', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-ScheduCal-Signature')
timestamp = request.headers.get('X-ScheduCal-Timestamp')
body = request.get_data(as_text=True)
if not verify_signature(WEBHOOK_SECRET, timestamp, body, signature):
return 'Invalid signature', 401
event = request.json
# Process the event...
print(f"Received {event['eventType']} for {event['data']['appointmentId']}")
return 'OK', 200
using System.Security.Cryptography;
using System.Text;
public bool VerifySignature(string secret, string timestamp, string body, string signature)
{
var signedPayload = $"{timestamp}.{body}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(signedPayload));
var expectedSignature = Convert.ToBase64String(hash);
var receivedSignature = signature.Replace("sha256=", "");
return expectedSignature == receivedSignature;
}
If your endpoint returns a non-2xx status code or times out, ScheduCal will retry with exponential backoff:
| Attempt | Delay After Failure |
|---|---|
| 1 | 30 seconds |
| 2 | 1 minute |
| 3 | 2 minutes |
| 4 | 5 minutes |
| 5 | 15 minutes |
| 6 | 30 minutes |
After 6 failed attempts, the delivery is marked as permanently failed.
If your webhook endpoint fails 10 consecutive times (across any deliveries), it will be automatically disabled. You can re-enable it via the Update Webhook API:
curl -X PATCH "https://api.scheducal.com/api/v1/webhooks/{webhookId}" \
-H "Content-Type: application/json" \
-d '{
"apiKey": "your-api-key",
"apiSecret": "your-api-secret",
"isActive": true
}'
Return a 200 response as soon as possible (within 30 seconds). Process the webhook asynchronously if needed.
Webhooks may occasionally be delivered more than once. Use the id field to detect and ignore duplicates.
Always verify the signature in production to ensure webhooks are from ScheduCal.
Reject webhooks with timestamps too far in the past (e.g., > 5 minutes) to prevent replay attacks.
Webhook URLs must use HTTPS. HTTP URLs will be rejected.
Even if you only subscribe to certain events, gracefully handle unexpected event types in case new ones are added.
| Status Code | Description |
|---|---|
| 400 | Bad request (invalid payload, missing fields) |
| 401 | Invalid API credentials |
| 404 | Webhook not found |
| 500 | Internal server error |
Error response format:
{
"success": false,
"error": "Error message describing the issue"
}