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 have | In v2 |
|---|---|
API keys, sent in x-api-key | The same keys in the same header |
Stored accountIds | The same ids. v2 reads the same accounts. |
Stored calendar ids, including primary | Accepted as calendarRef |
| Stored event ids | The same ids, returned and accepted |
| Users connected through v1 | Available 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 endpoints | Unchanged and still working. v2 also has its own endpoints for the same two steps, listed below this table. |
| Webhook endpoints, their triggers and credentials | Kept. 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.meta | Still 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 |
|---|---|---|
summary | subject | title |
start.dateTime, start.date | start.dateTime, isAllDay | startsAt (UTC), startsAtLocal, isAllDay |
start.timeZone | start.timeZone (Windows names) | timezone (always IANA) |
attendees[].responseStatus | attendees[].status.response | attendees[].response |
hangoutLink, conferenceData | onlineMeeting | conferencing.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. Usemetadatainstead. privateExtendedPropertyandsharedExtendedPropertystill find Google events, as separate parameters or insidefilters.
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.
- 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.conflictscounts the events where a key held different values inprivateandshared. In that case, theprivatevalue 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": []
}
}
- Run the import. Send the same body without
dryRun. The response is202 Acceptedwith animportId, 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"
}'
- Check its progress with
GET /v2/metadata/imports/{importId}untilstatusisdone.counts.copiedis the number of events that received metadata. - 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 property | In 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 properties | A 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 name | The 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
| v1 | v2 |
|---|---|
GET /google/calendarsGET /microsoft/calendars | GET /v2/calendars |
GET /google/calendars/:idGET /microsoft/calendars/:id | GET /v2/calendars/{calendarRef} |
GET /google/eventsGET /microsoft/eventsGET /microsoft/events/calendarView | GET /v2/events |
POST /google/eventsPOST /microsoft/events | POST /v2/events |
GET /google/events/:idGET /microsoft/events/:id | GET /v2/events/{eventId} |
PATCH /google/events/:idPATCH /microsoft/events/:id | PATCH /v2/events/{eventId} |
DELETE /google/events/:idDELETE /microsoft/events/:id | DELETE /v2/events/{eventId} |
GET /google/events/:id/instancesGET /microsoft/events/:id/instances | GET /v2/events/{eventId}/instances |
GET /google/freebusyGET /microsoft/freebusy | GET /v2/free-busyPOST /v2/free-busy |
GET /zoom/meetings | GET /v2/meetings |
POST /zoom/meetings | POST /v2/meetings |
GET /zoom/meetings/:id | GET /v2/meetings/{meetingId} |
PATCH /zoom/meetings/:id | PATCH /v2/meetings/{meetingId} |
DELETE /zoom/meetings/:id | DELETE /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 parameter | In v2 |
|---|---|
calendarId | Accepted as calendarRef |
startDate | Accepted as from |
startTime | Accepted as from |
timeMin | Accepted as from |
endDate | Accepted as to |
endTime | Accepted as to |
timeMax | Accepted as to |
search | Accepted as q |
maxResults | Accepted as limit |
singleEvents | Accepted as expand |
nextPageToken | Accepted as cursor |
updatedMin | Accepted as updatedSince |
eventTypes | Accepted as eventType |
timeZone | Accepted and ignored |
maxAttendees | Accepted and ignored |
iCalUID | Rejected: retrieve the event by its ID instead. |
$filter | Rejected: v2 does not pass Outlook query filters through. Use v2's own filters, and read extended properties with include=extendedProperties and extendedPropertyIds. |
$expand | Rejected: read extended properties with include=extendedProperties and extendedPropertyIds, or the provider's whole event with include=raw. |
showHiddenInvitations | Rejected: 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 parameter | In v2 |
|---|---|
calendarId | Accepted as calendarRef |
$expand | Rejected: 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 parameter | In v2 |
|---|---|
calendarId | Accepted as calendarRef |
sendUpdates | Accepted 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 parameter | In v2 |
|---|---|
calendarId | Accepted as calendarRef |
startDate | Accepted as from |
endDate | Accepted as to |
timeMin | Accepted as from |
timeMax | Accepted as to |
Get free/busy for one accountGET /v2/free-busy
| v1 parameter | In v2 |
|---|---|
calendarIds | Accepted as calendarRefs |
timeMin | Accepted as from |
timeMax | Accepted 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
- Create a test key and run your v2 requests against the sandbox.
- Migrate reads first. Update the path and read
datain the v2 format. Watch your logs forPyas-Deprecated-Fieldsand rename parameters when it's convenient. - 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. - 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.