Webhooks

Pyas sends an HTTPS request to your endpoint when something changes, for example when an event is created or moved in a calendar app, when a connection breaks, or when a meeting starts. Every delivery is signed, and a delivery that fails is retried for up to 41 hours. Pyas also stores each delivery so you can replay it.

Add an endpoint

To receive webhooks, create an endpoint with your handler's URL and the event types you want to receive:

const { endpoint, signingSecret } = await pyas.webhooks.create({
  url: 'https://yourapp.com/webhooks/pyas',
  events: ['event.*', 'connection.broken'],
})

events accepts exact event types, families such as event.*, or * for every type, including types added later. The default is *. An endpoint created with a test key receives events from the sandbox, and an endpoint created with a live key receives your users' events. You can also manage endpoints in the dashboard.

To verify deliveries, you need the endpoint's signing secret (whsec_...). The response that creates the endpoint includes it as signingSecret. To get it again later, call:

GET /v2/webhooks/{webhookId}/secret

Verify every delivery

Each delivery includes a Pyas-Signature header in the format t=<unix seconds>,v1=<hex>. The v1 value is an HMAC-SHA256 of <t>.<raw body>, computed with the endpoint's secret. Verify the raw body exactly as you received it, before you parse it. Reject signatures that are older than 5 minutes.

import express from 'express'
import { constructWebhookEvent } from '@pyas/sdk'

app.post('/webhooks/pyas', express.raw({ type: 'application/json' }), async (req, res) => {
  let event
  try {
    event = await constructWebhookEvent(req.body, req.header('pyas-signature'), process.env.PYAS_WEBHOOK_SECRET)
  } catch {
    return res.status(400).end()
  }
  res.status(200).end()          // respond first, then process the event
  await handle(event)
})

To rotate an endpoint's signing secret, call:

POST /v2/webhooks/{webhookId}/secret/rotate

Rotating starts a grace period, which is 24 hours by default. During the grace period, each delivery includes one v1 signature for each secret, and either signature verifies the delivery.

What a delivery looks like

{
  "id": "evt_Q3kx9TzLmP2wVb7Rn4Ya",
  "type": "event.updated",
  "apiVersion": "2",
  "createdAt": "2026-10-20T14:03:11Z",
  "livemode": true,
  "appId": "5c1e9a47-2d3b-4f86-a0e9-7b4c2d18f365",
  "accountId": "6b0e3f1a-8c2d-4e5f-9a7b-1c3d5e7f9a2b",
  "subjectId": "user_42",
  "provider": "google",
  "data": {
    "object": {
      "id": "6v1q8b3k2m9t0c4p7r5s",
      "calendarId": "primary",
      "seriesId": null,
      "isSeries": false
    }
  },
  "request": { "traceId": "trc_..." }
}
  • For event.*, calendar.* and invitation.replied, data.object tells you which event or calendar changed. It holds IDs only, never titles, times or attendees. Read the event or calendar to get its details. For connection.*, api_key.* and meeting.*, data.object is the account, the key or the meeting itself. See Event types.
  • request.traceId identifies the API request that caused the change. When no API request caused it, for example a change made in a calendar app, request is null, so check it before you read traceId.
  • id is the same on every retry of an event, so you can skip any id that you've already handled.

For event.* and invitation.replied, data.object has:

FieldWhat it is
idThe event's ID. When isSeries is true, the ID of the recurring series.
calendarIdThe calendar the event is on.
seriesIdFor one occurrence of a recurring series that changed on its own, the series' ID. Otherwise null.
isSeriestrue when the change was to a whole recurring series.

For calendar.*, data.object has the calendar's id.

Get the details of what changed

  1. For event.created or event.updated, read the event with the delivery's accountId and the object's calendarId:
    GET /v2/events/6v1q8b3k2m9t0c4p7r5s?accountId={accountId}&calendarRef=primary
    

    When isSeries is true, the whole series changed. To refresh its occurrences, list them:
    GET /v2/events/6v1q8b3k2m9t0c4p7r5s/instances?accountId={accountId}&calendarRef=primary&from=2026-10-01T00:00:00Z&to=2026-12-31T00:00:00Z
    
  2. For event.deleted, remove the event with that id from your records. When isSeries is true, remove the series and its occurrences.
  3. For calendar.created or calendar.updated, read the calendar:
    GET /v2/calendars/{calendarId}?accountId={accountId}
    

If you need to know what an event was before a change, keep your own copy of the events you sync and compare it with what you read.

Each delivery includes the Pyas-Event-Id, Pyas-Event-Type, Pyas-Delivery-Id, Pyas-Timestamp, and Pyas-Signature headers. If you set a credential on the endpoint with auth (a header or a bearer token), each delivery also includes that credential.

Event types

TypeWhen
connection.created, connection.reauthorizedA user connected an account, or reconnected a broken connection
connection.broken, connection.deletedA connected account can no longer sync, or was deleted
calendar.created, calendar.updated, calendar.deletedA calendar was created, updated, or deleted
event.created, event.updated, event.deletedAn event was created, updated, or deleted, through the API or in a calendar app
invitation.repliedSomeone responded to an invitation sent from a Pyas calendar
meeting.started, meeting.ended, meeting.updated, meeting.deletedA Zoom meeting started, ended, was updated, or was deleted
meeting.participant_joined, meeting.participant_leftSomeone joined or left a meeting
recording.readyA meeting's recording is ready to fetch
api_key.expiring, api_key.revokedAn API key is about to expire, or was revoked
webhook.pingA test delivery

To get every event type with a sample payload for each, call:

GET /v2/webhooks/event-types

Retries

A delivery succeeds when your endpoint responds with any 2xx status. If your endpoint responds with any other status, or doesn't respond within 10 seconds, Pyas retries the delivery after 30 seconds, 2 minutes, 15 minutes, 1 hour, 4 hours, 12 hours, and 24 hours. That's 8 attempts over about 41 hours. Respond quickly, and process the event after you respond.

If an endpoint fails 50 deliveries in a row, Pyas disables it and marks it as disabled in the dashboard. When you enable the endpoint again, Pyas sends a test delivery first. Events are stored for 30 days.

Replay

To resend a single delivery, call:

POST /v2/webhooks/deliveries/{deliveryId}/replay

To recover after an outage on your side, replay the deliveries that failed since a given time. Each request replays up to 1,000 failed deliveries. Send a body such as { "since": "2026-10-19T00:00:00Z" }:

POST /v2/webhooks/{webhookId}/replay

To list every delivery attempt, including when it was made, the status code your endpoint returned and how long it took, call:

GET /v2/webhooks/deliveries

Test before you build

There are three ways to test webhooks while you build.

Send a test delivery. Send a request with a body such as { "type": "event.updated" } to the endpoint below. Pyas immediately sends a signed sample of that event type to your endpoint. Test deliveries are marked "test": true and aren't retried.

POST /v2/webhooks/{webhookId}/test

Use the sandbox inbox. Every sandbox includes an inbox hosted by Pyas that receives every sandbox event, so you can inspect real deliveries before your endpoint exists.

Forward events to your machine. During development, the CLI forwards your sandbox's events to your local machine, signed the same way as in production:

npx @pyas/cli webhooks listen --forward http://localhost:3000/webhooks/pyas

The CLI prints a signing secret for the forwarding session. Use that secret to verify deliveries while you develop.

Endpoints on v1 payloads

Endpoints registered on v1 continue to receive v1 payloads, so your integration keeps working while you migrate. v1 deliveries aren't signed, and each one is sent once. A v1 delivery that fails isn't retried and can't be replayed.

Move an endpoint to v2

You can move an endpoint in either of two ways, and both can keep the URL you already have.

Switch the endpoint. When your handler is ready, send the body { "payloadVersion": "v2" } to:

PATCH /v2/webhooks/{webhookId}

From then on, the endpoint receives signed, retried v2 deliveries for the event types that correspond to its v1 triggers, and it stops receiving v1 payloads. The response includes the endpoint's new signing secret.

Add a v2 endpoint beside it. Create a v2 endpoint, at the same URL or a new one, and keep the v1 endpoint until your v2 handler is ready. While both exist, each change arrives twice, once in each format, so act on only one of them. Delete the v1 endpoint when you finish. Each endpoint counts toward your plan's endpoint limit.

Accept both formats at one URL

If one URL receives both formats while you migrate, check each request before you parse it:

  • A v2 delivery has a Pyas-Signature header, and its body has "apiVersion": "2". Verify the signature before you act on it.
  • A v1 delivery has an x-pyas-webhook-event header that names its v1 trigger, and no signature.

In the dashboard, an endpoint's page lists its deliveries in the format the endpoint receives now. If its URL also received the other format in the last 30 days, a switch above the list shows those deliveries too. v1 deliveries show their response status, and you can't open or replay them.

See Migrating from v1.