Using API v1? See the v1 docs

Migrating from v1

To move to v2, you change the paths you call and how you read the responses. Your API keys, your users' connected accounts, the ids you've stored and your webhook endpoints all carry over. Your users don't need to reconnect, and you don't need to register anything again.

You can migrate one endpoint at a time. v1 keeps working, and v1 and v2 requests count toward the same rate limit, so running both side by side doesn't cost anything extra.

What stays the same

What you haveIn v2
API keys, sent in x-api-keyThe same keys in the same header
Stored accountIdsThe same ids. v2 reads the same accounts.
Stored calendar ids, including primaryAccepted as calendarRef
Stored event idsThe same ids, returned and accepted
Users connected through v1Available in v2 right away. One exception: v1 didn't ask Google for calendar settings, so before you can read a Google account's settings (GET /v2/accounts/:accountId/settings), its user reconnects once. Until then that request returns CONNECTION_SCOPE_INSUFFICIENT.
Connecting accounts with PyasConnect or the v1 endpointsUnchanged and still working. v2 also has its own endpoints for the same two steps, listed below this table.
Webhook endpoints, their triggers and credentialsKept. They still receive v1 payloads until you switch each one.
v1 query parameters (calendarId, timeMin, startDate...)Accepted, and listed in a Pyas-Deprecated-Fields header (see the full list)
The v1 request body ({ accountId, calendarId, eventData })Accepted as is
Extended properties you write in eventData.metaStill written when you send the v1 request body. Move them to metadata when it suits you (see how).

These are the v1 endpoints for connecting accounts:

GET /{provider}/oauth
POST /{provider}/connect

In v2, the same two steps are these endpoints. See Connecting accounts.

GET /v2/connect/oauth?provider=
POST /v2/connect/accounts

What changes

The path. v2 paths don't include the provider. /google/events and /microsoft/events both become /v2/events, and the API determines each account's provider for you.

The response format. v1 returned Google's or Microsoft's own objects. v2 returns a single event format for every provider, so you need one parser instead of two. While you migrate, you can add include=raw to include the provider's original object as well, so you can still read every field you read today.

Google (v1)Microsoft (v1)v2
summarysubjecttitle
start.dateTime, start.datestart.dateTime, isAllDaystartsAt (UTC), startsAtLocal, isAllDay
start.timeZonestart.timeZone (Windows names)timezone (always IANA)
attendees[].responseStatusattendees[].status.responseattendees[].response
hangoutLink, conferenceDataonlineMeetingconferencing.joinUrl
recurrence (a list of rules)recurrence (a pattern object)recurrence (one RRULE)

Errors. In v1, error was either text or the provider's error. In v2, error is always an object with a code, a message, a hint and a traceId. See Errors for the full format. Existing code that checks success works without changes, but code that prints error should print error.message instead.

Pagination. List responses include meta.nextCursor, which you send in your next request as cursor. See Pagination. The API still accepts a v1 nextPageToken from a Google response.

Test keys. v2 has a sandbox that you access with test keys (pyas_sk_test_...). Test every v2 request there before you switch production.

Extended properties

In v1 you stored your own data on events as the provider's extended properties, in eventData.meta. In v2 you store it in metadata. Metadata works the same on Google and Outlook, holds text, numbers, true and false, and lists of those, and you can search for events by it across your whole app.

While you move:

  • Requests that send the v1 request body keep writing extended properties exactly as v1 did.
  • The v2 request body doesn't accept extended properties in providerOptions. Use metadata instead.
  • privateExtendedProperty and sharedExtendedProperty still find Google events, as separate parameters or inside filters.

Responses to requests that still use extended properties include this header:

Pyas-Deprecated-Fields: meta extended properties -> metadata

Read them

Add include=extendedProperties to a request that reads events. On Google, you get Google's private and shared values:

curl "https://api.pyas.io/v2/events/EVENT_ID?accountId=ACCOUNT_ID&include=extendedProperties" \
  -H "x-api-key: $PYAS_API_KEY"
{
  "success": true,
  "data": {
    "id": "EVENT_ID",
    "title": "Dinner with Sam",
    "extendedProperties": {
      "private": { "orderId": "A-1007", "stage": "paid" },
      "shared": { "room": "4" }
    }
  }
}

On Outlook, name the properties in extendedPropertyIds, separated by commas (up to 10), because Outlook returns them only by ID. Each comes back under its name, as a number, true or false, or text, depending on its type. Multi-value properties come back as lists, and a property without a name (such as Integer 0x6801) comes back under its ID:

curl -G "https://api.pyas.io/v2/events/EVENT_ID" \
  --data-urlencode "accountId=ACCOUNT_ID" \
  --data-urlencode "include=extendedProperties" \
  --data-urlencode "extendedPropertyIds=String {66f5a359-4659-4830-9070-00047ec6ac6e} Name orderId,IntegerArray {66f5a359-4659-4830-9070-00047ec6ac6e} Name counts" \
  -H "x-api-key: $PYAS_API_KEY"
{
  "success": true,
  "data": {
    "id": "EVENT_ID",
    "title": "Dinner with Sam",
    "extendedProperties": {
      "orderId": "A-1007",
      "counts": [3, 4]
    }
  }
}

Copy them into metadata

The import copies extended properties into metadata for the accounts you choose. It only reads the calendars and doesn't change them, so your v1 code keeps working while it runs.

  1. Check what the import would copy. Send dryRun: true, which reads a sample of recent events and writes nothing. For Google, the response lists every key it found, with examples and the scopes it was found in. conflicts counts the events where a key held different values in private and shared. In that case, the private value is the one imported. For Outlook, name each property by its ID. A property found on 0 events usually means a mistyped ID.
curl -X POST "https://api.pyas.io/v2/metadata/import" \
  -H "x-api-key: $PYAS_API_KEY" -H "Content-Type: application/json" \
  -d '{
    "allAccounts": true,
    "microsoft": { "properties": ["String {66f5a359-4659-4830-9070-00047ec6ac6e} Name orderId"] },
    "convert": "auto",
    "dryRun": true
  }'
{
  "success": true,
  "data": {
    "dryRun": true,
    "sampled": { "accounts": 2, "accountsInAll": 2, "calendars": 2, "events": 214 },
    "google": {
      "keys": [
        { "key": "orderId", "events": 96, "examples": ["A-1007", "A-1012"], "scopes": ["private"], "conflicts": 0, "canBeMetadataKey": true },
        { "key": "stage", "events": 41, "examples": ["paid", "draft"], "scopes": ["private", "shared"], "conflicts": 3, "canBeMetadataKey": true }
      ]
    },
    "microsoft": {
      "properties": [
        { "id": "String {66f5a359-4659-4830-9070-00047ec6ac6e} Name orderId", "key": "orderId", "events": 112, "examples": ["B-220", "B-231"] }
      ]
    },
    "wouldCopy": 208,
    "unchanged": 0,
    "wouldSkip": 0,
    "skipped": [],
    "errors": []
  }
}
  1. Run the import. Send the same body without dryRun. The response is 202 Accepted with an importId, and the import runs in the background.
curl -X POST "https://api.pyas.io/v2/metadata/import" \
  -H "x-api-key: $PYAS_API_KEY" -H "Content-Type: application/json" \
  -d '{
    "allAccounts": true,
    "microsoft": { "properties": ["String {66f5a359-4659-4830-9070-00047ec6ac6e} Name orderId"] },
    "convert": "auto"
  }'
  1. Check its progress with GET /v2/metadata/imports/{importId} until status is done. counts.copied is the number of events that received metadata.
  2. Switch your code to read and write metadata. Run the import again just before you stop writing extended properties, to copy anything written since the first run. Running it again is safe: an event that already holds the same values is left as it is, and values you've already set in v2 are kept unless you send "overwrite": true.

How values arrive in metadata:

Extended propertyIn metadata
Google private: { "orderId": "A-1007" }"orderId": "A-1007"
Google value "42" or "true", with "convert": "auto"42 or true
Google value '["Food","Hiking"]', with "convert": "auto"["Food", "Hiking"]
Outlook String {...} Name orderId"orderId": "A-1007"
Outlook Integer, Double, Boolean and the other typed propertiesA number or true/false, whatever convert says
Outlook StringArray {...} Name tags, and the other multi-value types"tags": ["Food", "Hiking"]
Outlook Integer 0x6801, a property without a nameThe key you choose, as shown below

A property without a name needs a key. Send it in microsoft.properties with the key you want:

"microsoft": {
  "properties": [
    "String {66f5a359-4659-4830-9070-00047ec6ac6e} Name orderId",
    { "id": "Integer 0x6801", "key": "legacyCount" }
  ]
}

You can also rename a named property the same way. Each key holds up to 100 list items, and an event's metadata is at most 50 keys and 8 KB.

Every v1 endpoint and its v2 equivalent

v1v2
GET /google/calendarsGET /microsoft/calendarsGET /v2/calendars
GET /google/calendars/:idGET /microsoft/calendars/:idGET /v2/calendars/{calendarRef}
GET /google/eventsGET /microsoft/eventsGET /microsoft/events/calendarViewGET /v2/events
POST /google/eventsPOST /microsoft/eventsPOST /v2/events
GET /google/events/:idGET /microsoft/events/:idGET /v2/events/{eventId}
PATCH /google/events/:idPATCH /microsoft/events/:idPATCH /v2/events/{eventId}
DELETE /google/events/:idDELETE /microsoft/events/:idDELETE /v2/events/{eventId}
GET /google/events/:id/instancesGET /microsoft/events/:id/instancesGET /v2/events/{eventId}/instances
GET /google/freebusyGET /microsoft/freebusyGET /v2/free-busyPOST /v2/free-busy
GET /zoom/meetingsGET /v2/meetings
POST /zoom/meetingsPOST /v2/meetings
GET /zoom/meetings/:idGET /v2/meetings/{meetingId}
PATCH /zoom/meetings/:idPATCH /v2/meetings/{meetingId}
DELETE /zoom/meetings/:idDELETE /v2/meetings/{meetingId}

Zoom's meeting endpoints (/zoom/meetings) are /v2/meetings in v2. Deleting a connected account is DELETE /v2/accounts/{accountId} in v2. Deactivating one is still available only in v1:

PATCH /accounts/deactivate/:id

You can also deactivate an account from the dashboard. Connecting accounts through the v1 endpoints still works, and accounts connected that way are available in v2.

v1 parameters that v2 still accepts

Each v2 endpoint below accepts the v1 query parameters and responds normally. The response lists the v1 parameters you used in a Pyas-Deprecated-Fields header, so you can see which ones to rename when it's convenient. For example:

Pyas-Deprecated-Fields: timeZone (no effect), calendarId -> calendarRef

Times sent in v1 parameters without a time zone are read as UTC, the same as in v1.

List eventsGET /v2/events

v1 parameterIn v2
calendarIdAccepted as calendarRef
startDateAccepted as from
startTimeAccepted as from
timeMinAccepted as from
endDateAccepted as to
endTimeAccepted as to
timeMaxAccepted as to
searchAccepted as q
maxResultsAccepted as limit
singleEventsAccepted as expand
nextPageTokenAccepted as cursor
updatedMinAccepted as updatedSince
eventTypesAccepted as eventType
timeZoneAccepted and ignored
maxAttendeesAccepted and ignored
iCalUIDRejected: retrieve the event by its ID instead.
$filterRejected: v2 does not pass Outlook query filters through. Use v2's own filters, and read extended properties with include=extendedProperties and extendedPropertyIds.
$expandRejected: read extended properties with include=extendedProperties and extendedPropertyIds, or the provider's whole event with include=raw.
showHiddenInvitationsRejected: v2 doesn't return hidden invitations.
  • A `maxResults` above 250 is capped. The remaining events are returned on the next page.
  • `orderBy=updated` maps to `sort=updated`. `orderBy=startTime` is the order of an expanded window (`expand=true`).
  • `showDeleted=true` also returns cancelled events. On an incremental sync, deleted events are listed in `meta.deletedIds`.
  • For `calendarView`, send `from`, `to` and `expand=true`. This returns the window with recurring series expanded.
  • `privateExtendedProperty` and `sharedExtendedProperty` (`key=value`, repeatable) still search Google events by extended property, to help you move. Search by `metadata.<key>` instead.
  • Outlook's `$filter` and `$expand` are refused. Use v2's own filters, and read extended properties with `include=extendedProperties` and `extendedPropertyIds`.

Create an eventPOST /v2/events

  • The v1 request body (`{ accountId, calendarId, eventData }`) is accepted and converted to the v2 format.

Get an eventGET /v2/events/{eventId}

v1 parameterIn v2
calendarIdAccepted as calendarRef
$expandRejected: read extended properties with include=extendedProperties and extendedPropertyIds, or the provider's whole event with include=raw.

Update an eventPATCH /v2/events/{eventId}

  • The v1 request body (`{ accountId, calendarId, eventData }`) is accepted and converted to the v2 format.

Delete an eventDELETE /v2/events/{eventId}

v1 parameterIn v2
calendarIdAccepted as calendarRef
sendUpdatesAccepted as notifyAttendees
  • `sendUpdates=none` maps to `notifyAttendees=false`. `all` and `externalOnly` map to `notifyAttendees=true`.

List occurrences of a recurring eventGET /v2/events/{eventId}/instances

v1 parameterIn v2
calendarIdAccepted as calendarRef
startDateAccepted as from
endDateAccepted as to
timeMinAccepted as from
timeMaxAccepted as to

Get free/busy for one accountGET /v2/free-busy

v1 parameterIn v2
calendarIdsAccepted as calendarRefs
timeMinAccepted as from
timeMaxAccepted as to

Get free/busy for several accountsPOST /v2/free-busy

  • Accepts several accounts in one request. For one account with the v1 query parameter names, use `GET /v2/free-busy`.

List meetingsGET /v2/meetings

  • v1's `maxResults` is `limit`, `nextPageToken` is `cursor`, and `startDate` and `endDate` are `from` and `to`.

Create a meetingPOST /v2/meetings

  • v1's `meetingData` fields are top level: `title`, `description`, `startsAt` (was `startDate`), `durationMinutes` (was `duration`), `timezone` (was `timeZone`), `attendees`, `recurrence` (an RRULE) and `settings` (`admission: organizer` for v1's `waitingRoom: true`).

Get a meetingGET /v2/meetings/{meetingId}

  • v1 returned the meeting in `meeting`; v2 returns it in `data`, like every v2 read.

Update a meetingPATCH /v2/meetings/{meetingId}

  • `accountId` is in the query. v1 resent every field with a new passcode; v2 changes only what you send.

Delete a meetingDELETE /v2/meetings/{meetingId}

  • v1 always sent the cancellation email; in v2 it is `notify=true`.

Webhooks

Endpoints you registered in v1 keep receiving v1 payloads. When your handler is ready for v2 payloads, switch each endpoint by sending { "payloadVersion": "v2" } to:

PATCH /v2/webhooks/{webhookId}

From then on, the endpoint receives v2 deliveries for the event types that match its v1 triggers. These deliveries are signed, retried for 41 hours, and can be replayed. The response includes the endpoint's signing secret.

You can also keep the v1 endpoint while you build your v2 handler: add a v2 endpoint beside it, at the same URL or a new one, and delete the v1 endpoint when you finish. While both exist, each change arrives in both formats. If one URL receives both, tell them apart by their headers: a v2 delivery has a Pyas-Signature header, and a v1 delivery has x-pyas-webhook-event. The endpoint's page in the dashboard shows the deliveries of either format.

A plan for the move

  1. Create a test key and run your v2 requests against the sandbox.
  2. Migrate reads first. Update the path and read data in the v2 format. Watch your logs for Pyas-Deprecated-Fields and rename parameters when it's convenient.
  3. Migrate writes. Your v1 request bodies work as they are. Switch to the v2 format, with an Idempotency-Key, the next time you work on that code. If you write extended properties, copy them into metadata first.
  4. Move your webhook endpoints one at a time, and verify signatures as you go. Until an endpoint is on v2, its handler accepts v1 payloads, so a URL that receives both formats checks the headers of each request.