# Pyas API ::lead One API for Google Calendar, Microsoft Outlook, and Zoom. Connect your users' calendars, read and write their events, find times when they're free, and get notified about every change as it happens. :: ::div{class="not-prose mt-6 mb-16 flex gap-3"} :::link-button{href="/quickstart" arrow="right"} Quickstart ::: :::link-button{href="/sdks" variant="outline"} SDKs and tools ::: :: ::section-heading{id="getting-started" anchor="false"} Getting started :: ::lead Create an app in the [dashboard](https://app.pyas.io) and copy its test key. You can start sending requests right away. A test key works only in your app's sandbox, which includes sample people with a week of events on Pyas calendars, as well as simulated Google Calendar, Microsoft Outlook, and Zoom accounts. The sandbox doesn't connect to any real calendar. When your integration works, switch to your live key and connect your users' calendars. :: ::div{class="not-prose"} :::link-button{href="/authentication" variant="text" arrow="right"} Get your API key ::: :: ::note Already using v1? Your API keys, connected accounts and webhook endpoints carry over to v2, and your users don't need to reconnect. See [Migrating from v1](/migrating-from-v1). :: ::guides :: ::resources :: # Quickstart ::lead In this guide, you send your first requests to your app's sandbox, where sample people already have a week of events. The sandbox doesn't connect to any real calendar. When your integration works, you can connect a real one. :: ## Get a test key Sign in to the [dashboard](https://app.pyas.io), open your app (or create one), and copy a test key from **API keys**. Test keys start with `pyas_sk_test_` and work only in your app's sandbox, so you can test anything without affecting real calendars. ```bash export PYAS_API_KEY=pyas_sk_test_... ``` ## Install the SDK The SDK is optional. Every request is a plain HTTPS request with your key in the `x-api-key` header. Each example on this page shows both the SDK and cURL. To send them from Postman instead, see [Postman](/sdks#postman). ```bash npm install @pyas/sdk ``` ## List the connected accounts Your sandbox includes sample people, and each one has a connected account with a Pyas calendar. Every request that reads or writes calendar data must include an `accountId`, so start by listing the accounts. ::code-group ```js [Node.js] import { Pyas } from '@pyas/sdk' const pyas = new Pyas({ apiKey: process.env.PYAS_API_KEY }) for await (const account of pyas.accounts.list()) { console.log(account.accountId, account.name, account.provider) } ``` ```bash [cURL] curl https://api.pyas.io/v2/accounts \ -H "x-api-key: $PYAS_API_KEY" ``` :: ## Read a week of events Pass an `accountId` from the previous step and a time window in `from` and `to`. With `expand=true`, each occurrence of a recurring event is returned separately. ::code-group ```js [Node.js] const from = new Date() const to = new Date(Date.now() + 7 * 86_400_000) const page = await pyas.events.list({ accountId, from: from.toISOString(), to: to.toISOString(), expand: true }) for (const event of page.data) { console.log(event.startsAt, event.title) } ``` ```bash [cURL] curl "https://api.pyas.io/v2/events?accountId=$ACCOUNT_ID&from=2026-10-20T00:00:00Z&to=2026-10-27T00:00:00Z&expand=true" \ -H "x-api-key: $PYAS_API_KEY" ``` :: Events have the same structure on Google Calendar, Microsoft Outlook and Pyas calendars. Every event includes: - `startsAt` and `endsAt` in UTC. - The same times in the calendar's own time zone (`startsAtLocal`, `endsAtLocal` and `timezone`). - Attendees and conferencing details. - Your own `metadata`. ## Create an event ::code-group ```js [Node.js] const event = await pyas.events.create({ accountId, title: 'Intro call', startsAt: '2026-10-21T15:00:00Z', endsAt: '2026-10-21T15:30:00Z', attendees: [{ email: 'sam.patel@example.org' }], }) console.log(event.id, event.startsAtLocal) ``` ```bash [cURL] curl -X POST https://api.pyas.io/v2/events \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "accountId": "'$ACCOUNT_ID'", "title": "Intro call", "startsAt": "2026-10-21T15:00:00Z", "endsAt": "2026-10-21T15:30:00Z", "attendees": [{ "email": "sam.patel@example.org" }] }' ``` :: The SDK sends an `Idempotency-Key` with every request that creates, changes or deletes something. If it retries the request after a dropped connection, the event isn't created twice. If you call the API with plain HTTP, send your own `Idempotency-Key` header. See [Retries and idempotency](/idempotency). ## See it in the dashboard To see the event you created, switch your app to **Test mode** in the dashboard, open **Accounts**, select the person and open their **Calendar** tab. When you select the event, the dashboard shows the request that last updated it. ## Connect a real calendar When your integration works in the sandbox, switch to a live key and let your users connect their own Google, Microsoft Outlook or Zoom accounts with [PyasConnect](/connect). The code you wrote for the sandbox works without changes, because the requests, the event structure and the errors are the same. ::note To receive notifications about changes instead of polling, set up [webhooks](/webhooks). During development, you can forward your sandbox's events to your local handler with the CLI command below. :: ```bash npx @pyas/cli webhooks listen --forward http://localhost:3000/webhooks ``` # SDKs and tools ::lead The Pyas API uses plain HTTPS and JSON, and an [OpenAPI document](https://api.pyas.io/v2/openapi.json) describes every endpoint. The tools on this page make common tasks quicker for you and for your coding agent. :: ::libraries :: ## Node.js and TypeScript The types in `@pyas/sdk` are generated from the OpenAPI document, so your editor can autocomplete every field. The SDK requires Node 20 or later and has no dependencies. ```bash npm install @pyas/sdk ``` ```js import { Pyas } from '@pyas/sdk' const pyas = new Pyas({ apiKey: process.env.PYAS_API_KEY }) const event = await pyas.events.get({ accountId, eventId }) ``` Every operation in the [API reference](/reference) has a method with a matching name, such as `pyas.events.list`, `pyas.calendars.permissions.create` and `pyas.webhooks.deliveries.replay`. Each method accepts a single object that contains the operation's path, query and body parameters. The exception is `events.attachments.upload`, which takes the file as its second argument. The SDK also does the following: - Retries rate-limited requests and network failures when it's safe to do so, and honors the `Retry-After` header. It sends an `Idempotency-Key` with every request that creates, changes or deletes something. See [Retries and idempotency](/idempotency). - Iterates through every page when you use `for await`, or returns a single page when you `await` the method. - Throws typed errors (such as `NotFoundError` and `RateLimitError`) that include the API's error code, hint, documentation link and trace id. - Verifies webhook signatures with `verifyWebhook`. ## The CLI You can use the `pyas` CLI in your terminal, or let your coding agent use it. Every command accepts `--json`. The CLI exits with code `2` when the command itself is invalid, and with code `1` when the API rejects the request. ```bash npx @pyas/cli login # signs you in through the dashboard, to set up your apps npx @pyas/cli apps show # the app's setup checklist and the next step npx @pyas/cli keys test # a test key, printed once npx @pyas/cli login --key pyas_sk_test_... # saves a key for the calendar commands npx @pyas/cli accounts list npx @pyas/cli events create --account --title "Intro call" --start 2026-10-21T15:00:00Z --duration 30 npx @pyas/cli errors explain CONNECTION_NOT_FOUND npx @pyas/cli logs get trc_... # what a request did, and why it failed npx @pyas/cli webhooks listen --forward http://localhost:3000/webhooks ``` `pyas login` shows a code in your terminal and opens the dashboard, where you enter the code and approve. Only approve a code from a terminal you're using. The sign-in lasts until you run `pyas logout` or disconnect it under **Connected tools** on your dashboard profile. A coding agent that can't wait for a command to finish can run `pyas login --no-wait --json`, show you the code, then run `pyas login --wait`. To call any operation in the reference, use `pyas request` with the operation's id: ```bash npx @pyas/cli request --params '{...}' ``` ## Postman The **Pyas API v2** collection has every endpoint, each with the example from the [API reference](/reference). The examples use your sandbox's sample people and events, so they work as soon as you add your key. 1. Open Pyas's public Postman workspace, [brutforce-tech/pyas-api](https://www.postman.com/brutforce-tech/pyas-api), and fork **Pyas API v2** into your own workspace. 2. In the collection's **Variables** tab, set `apiKey` to your test key. 3. Send any request. The **Scenarios** folder runs common tasks step by step, such as finding a time when everyone is free and booking it. Run it with the Collection Runner, and each step passes what the next one needs. ## MCP server To add Pyas to Claude, Cursor, VS Code or another AI tool by its address, with sign-in and nothing to install, see [AI tools](/ai-tools). The rest of this section runs the server on your own computer, with an API key. `@pyas/mcp` provides calendar tools for AI agents, such as tools to find a time, book it and move it. It also provides coding agents with access to the API reference, the error codes and your request logs. With a test key, the server works only in your sandbox. ```bash claude mcp add pyas --env PYAS_API_KEY=pyas_sk_test_... -- npx -y @pyas/mcp ``` For Claude Desktop, Cursor, Windsurf or VS Code, add the server to the app's MCP settings: ```json { "mcpServers": { "pyas": { "command": "npx", "args": ["-y", "@pyas/mcp"], "env": { "PYAS_API_KEY": "pyas_sk_test_..." } } } } ``` To provide only the tools that read data, add `--read-only` to the arguments. ## AI tools for your app `@pyas/ai-tools` gives the AI assistant in your own app calendar tools, for the Vercel AI SDK, OpenAI, the Claude API and LangChain. Each person's assistant has access to their calendars only, and asks them before it changes anything. See [AI in your app](/ai-in-your-app). ```bash npm install @pyas/ai-tools ``` ## PyasConnect PyasConnect is a sign-in dialog for your page. Your users choose a provider and sign in, and the account is saved. Your server needs a single endpoint, which is one line of code with the SDK: `pyas.connect.sessionHandler()`. See [Connecting accounts](/connect) for the setup. Every option is documented on npm: [`@pyas/connect-react`](https://www.npmjs.com/package/@pyas/connect-react), [`@pyas/connect-vue`](https://www.npmjs.com/package/@pyas/connect-vue), [`@pyas/connect-angular`](https://www.npmjs.com/package/@pyas/connect-angular) and [`@pyas/connect`](https://www.npmjs.com/package/@pyas/connect). ```bash npm install @pyas/connect-react # or @pyas/connect-vue, @pyas/connect-angular, @pyas/connect ``` ## For coding agents - [`llms.txt`](/llms.txt) lists every page of these docs, and [`llms-full.txt`](/llms-full.txt) contains all of them in a single file. - The [error codes endpoint](https://api.pyas.io/v2/errors) returns every error code with its cause and fix, as structured data. - Every error response includes a `traceId`. To see what the request did and why it failed, run `pyas logs get` or call: ```http GET /v2/logs/{traceId} ``` # Authentication ::lead Every request must include one of your app's API keys in the `x-api-key` header. You create keys in the dashboard, under your app's **API keys**. :: ```bash curl https://api.pyas.io/v2/accounts \ -H "x-api-key: pyas_sk_..." ``` Send the key only in the `x-api-key` header. If you send it in an `Authorization: Bearer` header instead, the API rejects the request with `AUTH_INVALID_KEY`, and the error's hint explains which header to use. Keep your API keys on your server. Code that runs in the browser never needs a key, even to connect accounts. See [Connecting accounts](/connect). The API refuses a live key sent from a web page with `AUTH_KEY_IN_BROWSER`, and emails your app's owner the first time it happens, because anyone who opens the page can read the key. A test key sent from a web page still works, but the response includes a `Pyas-Warning` header. Either way, the dashboard's **API keys** page shows which key was sent from a web page, and from which site. Requests from a server don't include an `Origin` header, so they are never affected. ## Live and test keys | | Live key | Test key | |---|---|---| | Starts with | `pyas_sk_` | `pyas_sk_test_` | | Has access to | Your users' connected calendars | Your app's [sandbox](/sandbox) only | | Counts toward | Your plan's monthly requests | A separate monthly allowance for testing | | Can connect accounts | Yes | No. The sandbox has its own accounts. | Live and test keys are kept separate. A test key can't read a real calendar, and a live key can't access the sandbox. Code that you write with a test key works without changes when you switch to a live key. ## Scopes A key has the `read` scope, the `write` scope, or both. The `read` scope allows `GET` requests, and the two `POST` requests that only read: free/busy and meeting times (see [Availability](/reference/availability)). Every other request requires the `write` scope, and so does reading a webhook's signing secret (`GET /v2/webhooks/{webhookId}/secret`). If a key doesn't have the scope that a request requires, the API returns `AUTH_INSUFFICIENT_SCOPE` with a `403 Forbidden` status. For example, you can give a reporting job a read-only key and grant `write` only to the services that write to calendars. ## Rotating a key When you rotate a key, the API creates a new key and keeps the old one working for a grace period. This lets you deploy the new key without any failed requests. The response includes the new key's value in `key`, the new key's details in `newKey`, and the old key's details in `previousKey`. The old key's value isn't returned: it's the key you sent the request with. ::code-group ```js [Node.js] // Send this request with the key you want to replace. const { key, newKey, previousKey } = await pyas.keys.rotate({ keyId: 'current', graceHours: 24 }) // key: the NEW key's value, such as 'pyas_sk_live_...'. Store it now, in place of the old // key: it's returned only in this response. // newKey: the new key's details (keyId, last4, scopes). Its value is key, above. // previousKey: the key you just rotated. It keeps working until previousKey.graceEndsAt, // 24 hours from now, so you have time to deploy the new key. ``` ```bash [cURL] curl -X POST https://api.pyas.io/v2/keys/current/rotate \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "graceHours": 24 }' ``` :: - `current` refers to the key that you send the request with. A key can rotate only itself, so to rotate a different key, send the request with that key or use the dashboard. - The grace period is 24 hours by default, with a maximum of 168 hours. A value of `0` makes the old key stop working immediately. - During the grace period, every response to a request made with the old key includes a `Pyas-Key-Expires-At` header. You can search your logs for this header to find any service you forgot to update. - Pyas sends an `api_key.expiring` [webhook](/webhooks) event 1 hour before the old key stops working, and an `api_key.revoked` event when it stops working. - You can rotate a key only once. The next time you rotate, rotate the new key. If a key has an expiration date and you don't rotate it, you receive an `api_key.expiring` event and an email 7 days before it expires. ## Authentication errors | Code | Status | Meaning | |---|---|---| | [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) | 401 | The key is missing, unknown or revoked, was sent somewhere other than the `x-api-key` header, or was replaced by a rotation whose grace period has ended | | [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) | 401 | The key has passed the expiry date it was created with | | [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) | 403 | The key doesn't have the scope that this request requires | | [`AUTH_KEY_IN_BROWSER`](/errors/AUTH_KEY_IN_BROWSER) | 403 | A live key was sent from a web page. Make the request from your server, and rotate the key | | [`AUTH_SANDBOX_MISMATCH`](/errors/AUTH_SANDBOX_MISMATCH) | 403 | A test key was used for an operation that only a live key can perform, or the reverse | # The sandbox ::lead Every app has a sandbox that you access with the app's test keys. The sandbox is ready on your first request and doesn't connect to any real calendar. It returns the same responses and errors as the live API. :: ## What's in the sandbox The sandbox starts with the people below. Each of them has a Pyas calendar with a week of events, including stand-ups, one-on-ones, a design review, focus time and an offsite. To add your own people, create accounts with: ```http POST /v2/accounts ``` | Person | Time zone | `subjectId` | |---|---|---| | Avery Brooks | America/New_York | `sandbox_user_1` | | Sam Patel | Europe/London | `sandbox_user_2` | | Yuki Tanaka | Asia/Tokyo | `sandbox_user_3` | | Jordan Lee | America/Los_Angeles | `sandbox_user_4` | | Riley Chen | UTC | `sandbox_user_5` | The sandbox also includes simulated provider accounts. Avery has a Google account and a Zoom account, and Sam has an Outlook account. These accounts behave like real Google, Outlook and Zoom accounts, with the same features and the same errors, but the API never contacts the providers for them. The following all behave as they would on a real account: Meet and Teams links, Zoom meetings, sharing roles, Outlook categories, calendar groups, invitations to respond to, attachments, reminders and working locations. To get the ids of the accounts, calendars and events in your sandbox, call: ```http GET /v2/sandbox ``` The response lists each id under the same name that the reference examples use, such as `account.avery` and `event.avery.design`. The [API reference](/reference) shows the ids from a sample sandbox. Your sandbox has the same people and events, but with its own ids. ## Reset the sandbox To restore the sandbox to the state it was in on your first request, call: ```http POST /v2/sandbox/reset ``` You can reset the sandbox between test runs. You can also reset it from the dashboard. In Test mode, go to **Accounts** and select **Reset sandbox**. ## Simulate changes On a live account, events change when people update their calendars, reply to invitations or join meetings. In the sandbox, you trigger these changes yourself with the endpoints below. The sandbox then sends the same [webhooks](/webhooks) that a live account sends. | Endpoint | What it simulates | |---|---| | `POST /v2/sandbox/simulate-change` | An event is created, updated or deleted on the account, as if the person did it in their own calendar app | | `POST /v2/sandbox/simulate-meeting` | A meeting starts or ends, someone joins or leaves, or a recording becomes available | | `POST /v2/sandbox/simulate-reply` | An attendee accepts, declines or responds with maybe, and can propose a different time | ```bash curl -X POST https://api.pyas.io/v2/sandbox/simulate-reply \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "accountId": "'$ACCOUNT_ID'", "eventId": "'$EVENT_ID'", "email": "sam.patel@example.org", "response": "declined" }' ``` For a reply, the event must be on one of the sample people's Pyas calendars, not on the simulated Google, Outlook or Zoom account, and `email` must be one of its attendees. This example replies for the attendee invited in the [Quickstart](/quickstart). ## Simulate provider errors To test how your app handles provider failures, add `?simulate=` (or a `Pyas-Simulate` header) to any request made with a test key. The request then fails in the same way it would if a real provider failed: | Value | Response | |---|---| | `rate_limit` | [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED), with `Retry-After` | | `token_expired` | [`CONNECTION_TOKEN_EXPIRED`](/errors/CONNECTION_TOKEN_EXPIRED). The user needs to sign in again. | | `revoked` | [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED). The user revoked your access. | | `provider_error` | [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) | | `timeout` | [`PROVIDER_TIMEOUT`](/errors/PROVIDER_TIMEOUT) | Your error handling code receives the same error codes, hints and HTTP status codes that it receives in production. ## Limits A sandbox can hold up to 25 accounts, 10 calendars per account and 5,000 events. Calendar feeds created in the sandbox work for 7 days. If a sandbox receives no requests for 30 days, it's deleted, and a new one is created on your next request. If you exceed a limit, the API returns `RESOURCE_LIMIT_REACHED`. Requests made with a test key don't count toward your plan's monthly request allowance. They have a separate monthly allowance that's large enough for test suites and CI. # Connecting accounts ::lead Before you can read or write a user's calendar, the user connects their Google, Microsoft Outlook or Zoom account to your app. You can set this up in two ways. You can add PyasConnect, a ready-made sign-in dialog, to your page, or you can build your own screens on the API. :: | | PyasConnect | Your own screens | |---|---|---| | What you build | One endpoint on your server, and the component on your page | The provider buttons, the redirects, and two requests from your server | | Requests from your server | One, from your endpoint | Two, one to get a sign-in URL and one to send the code | | Works with | Pyas Auth and Native Auth | Pyas Auth and Native Auth | With either option, your API key stays on your server, and only your server uses it. To connect real accounts, you need a live key. A test key works only in the sandbox, which already has its own accounts. ## Pyas Auth or Native Auth For each provider, you choose how people sign in. You set this in the dashboard, under your app's **Provider setup**. - **Pyas Auth** is the default. People sign in through Pyas's own OAuth app for Google, Microsoft or Zoom, so you don't need to set anything up. The provider's consent screen shows the Pyas name. - **Native Auth** uses your own OAuth app for Google, Microsoft or Zoom. The provider's consent screen shows your app's name. To use it, enter your OAuth app's client ID, its client secret and your callback URL under **Provider setup**. ## Additional scopes By default, a connection asks for the access most apps need: reading and writing events, reading calendars, free/busy and settings. Some features need more access, so the user is asked for it only when your app requests the feature. There are two ways to request them: - **In the dashboard,** under your app's **Provider setup**, in **Additional scopes**. Choose them once for each provider, and every connection from your app asks for them, in PyasConnect and on your own screens. - **In a request,** with `features` when you create a connect session or a sign-in URL. A request that includes `features` uses those instead of the dashboard's choice. | Feature | What it adds | |---|---| | `manageCalendars` | Create, rename, recolor and delete calendars, and add calendars to the person's list or remove them. | | `sharing` | Share a calendar with other people and change their access. | | `meetingSettings` | Change Google Meet and Microsoft Teams settings, such as who can join without waiting. | | `attachmentUploads` | Upload files to events. | | `rooms` | List meeting rooms and book them for events. | On Google, `manageCalendars`, `sharing`, `meetingSettings`, `rooms` and `attachmentUploads` need Native Auth for now. Google must approve this access for Pyas's own Google app first. If your app uses Pyas Auth for Google and requests one of them in `features`, the request fails with `CONNECTION_PROVIDER_NOT_CONFIGURED`. In the dashboard, they can't be chosen until then. With Native Auth, add each feature's scopes to your own Google app; the dashboard lists them, and any Google API to turn on. On Microsoft, every feature works with either option. For `rooms`, the user's Microsoft 365 administrator must approve the access, so people whose organization hasn't approved it can't connect while you request it. ## Option 1: PyasConnect PyasConnect is a sign-in dialog that you add to your page. It shows the provider buttons, handles the sign-in, blocked pop-ups and errors, and saves the connected account. Before you start, add your site to your app's **allowed origins** in the dashboard. PyasConnect works only on those origins. To try it first, open **Lab** in the dashboard. Pick your app, change how PyasConnect looks and behaves, and connect a real account in the preview, with no server code. The Lab then gives you the code for Vue, React, Angular or plain HTML, already filled in for your app. ### 1. Add an endpoint to your server PyasConnect calls this endpoint each time a user opens the dialog. With the SDK, the endpoint is one line of code: ::code-group ```js [Express] import { Pyas } from '@pyas/sdk' const pyas = new Pyas({ apiKey: process.env.PYAS_API_KEY }) app.post('/api/pyas-session', pyas.connect.sessionHandler()) ``` ```js [Next.js] // app/api/pyas-session/route.js export const POST = pyas.connect.sessionHandler() ``` :: **Optional: Save your own user id on the account.** If your app has its own id for each user, pass a function that returns the id of the user who is connecting. Pyas saves the id on the account as `subjectId`, so you can find that user's accounts later. ```js app.post('/api/pyas-session', pyas.connect.sessionHandler((req) => req.user.id)) ``` Replace `req.user.id` with wherever your app stores the id of the signed-in user. ### 2. Add the component to your page Give the component a function that calls your endpoint and returns the session token. When an account is connected, save its `accountId`: ::code-group ```jsx [React] import { PyasConnect } from '@pyas/connect-react' const getSessionToken = () => fetch('/api/pyas-session', { method: 'POST' }).then((r) => r.json()).then((s) => s.token) save(account.accountId)} /> ``` ```vue [Vue] ``` ```html [HTML] ``` :: PyasConnect shows a "Connect an account" button, with your app's name and icon from the dashboard. When a user connects an account, your handler receives the account: ```json { "accountId": "6b0e3f1a-8c2d-4e5f-9a7b-1c3d5e7f9a2b", "provider": "google", "email": "avery@yourcustomer.com", "name": "Avery Brooks", "subjectId": "user_42" } ``` With **Native Auth**, the provider redirects people to your callback URL instead of to Pyas. Add this code to your callback page so that it passes the result to the dialog: ```js import { postCallbackResult } from '@pyas/connect' postCallbackResult() ``` The dialog is available in English, Norwegian, Swedish, Danish, German, French, Spanish, Dutch and Portuguese (Brazil). It uses your page's language (``), then the browser's. To choose the language yourself, set `locale`, for example `locale="de"`. The pages Pyas shows in redirect mode work the same way: they use the browser's language, or add `&locale=de` to the session's `url`. For themes, custom text, a custom button and every other option, see the package documentation on npm: [`@pyas/connect-react`](https://www.npmjs.com/package/@pyas/connect-react), [`@pyas/connect-vue`](https://www.npmjs.com/package/@pyas/connect-vue), [`@pyas/connect-angular`](https://www.npmjs.com/package/@pyas/connect-angular), and [`@pyas/connect`](https://www.npmjs.com/package/@pyas/connect) for any other page. ## Option 2: Your own screens With this option, you show the provider buttons yourself. When a user selects one, your server requests a sign-in URL from the API and redirects the user to it. After the user signs in, they return to your app with a code, and your server sends that code to the API to save the account. First, set the page that users return to after they sign in. You do this under **Provider setup** in the dashboard: - **Pyas Auth**: Set the provider's **Redirect URL** to the page in your app that receives the code. Pyas redirects people to that page after they sign in. - **Native Auth**: Your callback URL is already set in Provider setup, and the provider redirects people to it. ### 1. Get a sign-in URL When the user selects a provider button, request a sign-in URL from your server, then redirect the user to the `url` in the response: ::code-group ```js [Node.js] import { Pyas } from '@pyas/sdk' const pyas = new Pyas({ apiKey: process.env.PYAS_API_KEY }) const { url } = await pyas.connect.oauth({ provider: 'google', state: 'user_42' }) res.redirect(url) ``` ```bash [cURL] curl "https://api.pyas.io/v2/connect/oauth?provider=google&state=user_42" \ -H "x-api-key: $PYAS_API_KEY" ``` :: ```json { "success": true, "data": { "provider": "google", "url": "https://accounts.google.com/o/oauth2/v2/auth?client_id=...&redirect_uri=...&response_type=code&scope=...&state=..." }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` | Parameter | Description | |---|---| | `provider` | `google`, `microsoft` or `zoom`. Required. | | `state` | Any value you choose, such as your id for the user. It's returned to you with the code. | | `loginHint` | An email address to prefill on the provider's sign-in page. | | `features` | Additional access to request, as a comma-separated list of `manageCalendars`, `sharing`, `meetingSettings`, `attachmentUploads` and `rooms`. See [Additional scopes](#additional-scopes). | ### 2. The user returns with a code After signing in, the user is redirected to your Redirect URL (Pyas Auth) or your callback URL (Native Auth). The URL includes `code` and your `state` as query parameters: ``` https://yourapp.com/connected/google?code=4/0AeanS0Z...&state=user_42 ``` If the user cancels or the provider denies access, there's no valid code, and the request in step 3 returns an error. Show your own message and let the user try again. ### 3. Exchange the code for an account From your server, send the code to the API. The API saves the account and returns it: ::code-group ```js [Node.js] const account = await pyas.connect.accounts.create({ provider: 'google', code: req.query.code, subjectId: req.query.state, }) await saveAccount(user, account.accountId) ``` ```bash [cURL] curl -X POST https://api.pyas.io/v2/connect/accounts \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "provider": "google", "code": "4/0AeanS0Z...", "subjectId": "user_42" }' ``` :: ```json { "success": true, "data": { "accountId": "6b0e3f1a-8c2d-4e5f-9a7b-1c3d5e7f9a2b", "provider": "google", "email": "avery@yourcustomer.com", "name": "Avery Brooks", "subjectId": "user_42", "existing": false, "grantedScopes": ["https://www.googleapis.com/auth/calendar"] }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` `subjectId` is optional. It's your own id for the user, and the API saves it on the account. Each code can be used only once. If the request fails, the error explains why, for example with `CONNECTION_CONSENT_FAILED` or `BILLING_CONNECTION_LIMIT`. See [Errors](/errors). If you're coming from v1, steps 1 and 3 replace these two v1 endpoints: ```http GET /{provider}/oauth POST /{provider}/connect ``` In v2, the provider is a parameter instead of part of the path. The v1 endpoints still work. ## Save the account id With either option, store the `accountId` with your user. Every request that reads or writes their calendar must include it. To find a user's accounts later, list them by the id you saved as `subjectId`: ```js for await (const account of pyas.accounts.list({ subjectId: user.id })) { /* ... */ } ``` Pyas also sends a `connection.created` [webhook](/webhooks) event for every new account. ## When a connection breaks A connection can stop working when a user revokes access, changes their password or leaves their company. To find the accounts that need attention, call: ```http GET /v2/accounts/status?overall=attention,broken ``` Each account in the response has an `overall` status: | `overall.status` | Meaning | |---|---| | `healthy` | The connection works. | | `attention` | The connection works, but some features are limited, for example because of a missing permission. `reason` explains the limitation. | | `broken` | The account no longer syncs. | The response is paginated, so keep requesting pages until `meta.nextCursor` is `null`. To check a single account, call: ```http GET /v2/accounts/{accountId}/status ``` When `overall.fix` is `reconnect`, the user needs to sign in again. **With PyasConnect**, send the account's `accountId` from your page to your endpoint, and return it from the function you pass to `sessionHandler`. The dialog then opens directly to that account: ::code-group ```js [Your server] app.use(express.json()) app.post('/api/pyas-reconnect', pyas.connect.sessionHandler((req) => ({ accountId: req.body.accountId }))) ``` ```js [Your page] const getSessionToken = () => fetch('/api/pyas-reconnect', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ accountId }), }).then((r) => r.json()).then((s) => s.token) ``` :: **With your own screens**, send the same two requests again, first to get a sign-in URL and then to send the code. When the same person signs in, the API reconnects the account. The account keeps its `accountId`, and `existing` is `true` in the response. Each account's status also includes `userMessage`, a sentence you can show the user. The `connection.broken` and `connection.reauthorized` [webhook](/webhooks) events notify you when an account stops syncing and when it's reconnected. # Events ::lead Events have the same format on every calendar. You read, write and search them the same way on Google, Outlook and Pyas calendars. When a provider doesn't support a feature, the API returns an error or lists the feature in the response, instead of silently skipping it. :: ## Choose a calendar Every event request must include an account (`accountId`) and can include a calendar (`calendarRef`). If you don't specify a calendar, the API uses the account's `primary` calendar. A `calendarRef` is either `primary` or a calendar id returned by this request: ```http GET /v2/calendars ``` ## Times and time zones | Field | Description | |---|---| | `startsAt`, `endsAt` | The time in UTC (`2026-10-21T15:00:00Z`) | | `startsAtLocal`, `endsAtLocal` | The same time in the event's time zone, with that zone's offset (`2026-10-21T11:00:00-04:00`) | | `timezone` | The IANA name of the event's time zone (`America/New_York`) | | `isAllDay` | Whether the event is an all-day event. If it is, its times are midnight in `timezone` (`startsAtLocal` is `2026-10-21T00:00:00-04:00`). | When you create or update an event, include a time zone offset with each time (`Z` or `+02:00`), or use plain dates (`2026-10-21`) for an all-day event. The API rejects a time without an offset, because the time is ambiguous without one. For the same reason, the `from` and `to` parameters for reading events also require an offset. ## Create, update and delete events ::code-group ```js [Node.js] const event = await pyas.events.create({ accountId, title: 'Weekly review', startsAt: '2026-10-21T15:00:00Z', endsAt: '2026-10-21T15:30:00Z', attendees: [{ email: 'sam.patel@example.org', name: 'Sam Patel' }], recurrence: 'FREQ=WEEKLY;COUNT=6', metadata: { dealId: 'deal_1007' }, }) await pyas.events.update({ accountId, eventId: event.id, title: 'Weekly review (moved)' }) await pyas.events.delete({ accountId, eventId: event.id, notifyAttendees: true }) ``` ```bash [cURL] curl -X POST https://api.pyas.io/v2/events \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "accountId": "'$ACCOUNT_ID'", "title": "Weekly review", "startsAt": "2026-10-21T15:00:00Z", "endsAt": "2026-10-21T15:30:00Z", "recurrence": "FREQ=WEEKLY;COUNT=6" }' ``` :: In an update request, include only the fields you want to update. To clear a field that can be empty, set it to `null`. When you create, update or delete an event that has attendees, they get an email about it. To stop the email, set `notifyAttendees` to `false`. Outlook always emails attendees, and Microsoft doesn't offer a way to stop it. On an Outlook calendar, the change is still made, and the response includes `meta.notifyAttendees` to tell you that attendees were emailed: ```json "meta": { "notifyAttendees": { "applied": false, "reason": "Outlook emails attendees about changes to events that have them, and Microsoft Graph has no setting to stop it." } } ``` ## Recurring events `recurrence` is an RFC 5545 rule (`FREQ=WEEKLY;BYDAY=MO,WE;COUNT=10`). A list request returns each series once. To get every occurrence in the time window instead, pass `expand=true`. To list the occurrences of a single series, call: ```http GET /v2/events/{eventId}/instances ``` Each occurrence includes a `series` field that identifies its series. When you update an occurrence, only that occurrence is updated. ## Meeting links To add a meeting link to an event, set `conferencing.provider`. You can use `meet` on a Google calendar, `teams` on an Outlook calendar, or `zoom` on any calendar. For Zoom, you also set `conferencing.accountId` to a connected Zoom account. The response includes `conferencing.joinUrl`, the meeting id, the passcode and the dial-in numbers. To configure the meeting, such as who can join without waiting, recording and muting participants on entry, use `conferencing.options`. The options are the same for all three services. If a service doesn't support a setting, the API rejects the request instead of ignoring the setting. If a service doesn't keep a setting, the response lists it in `meta.conferencing.notApplied`, along with the reason. ## Your own data on events Use `metadata` to store your own fields on any event. Values can be text, numbers, `true` and `false`, or lists of those: ```json "metadata": { "dealId": "deal_1007", "seats": 4, "tags": ["Food", "Hiking"] } ``` Metadata works with every provider, and you can search for events by these fields across your whole app. A search for a list's item finds every event whose list holds it: ```bash curl "https://api.pyas.io/v2/events?metadata.dealId=deal_1007" -H "x-api-key: $PYAS_API_KEY" curl "https://api.pyas.io/v2/events?metadata.tags=Hiking" -H "x-api-key: $PYAS_API_KEY" ``` When you update an event, the keys you send are changed and the others are kept. A list you send replaces the list that was there. To remove a key, send it as `null`. If you stored data as extended properties in v1, see [Migrating from v1](/migrating-from-v1#extended-properties). ## Read several calendars in one request To read events from several calendars in one request, pass `accountIds` with `from` and `to`. To include every connected account of one of your users, pass a `subjectId` instead of `accountIds`. The API returns the events as a single timeline sorted by start time, and each event's account is in `providerRefs.accountId`. When the same meeting appears on two calendars, the API either flags it as a duplicate (`duplicates=flag`, the default) or returns it only once (`duplicates=collapse`). ## Prevent conflicting updates Every event has a `version`, which the API also returns in the `ETag` header. When you update or delete the event, send that value in the `If-Match` header, exactly as you received it, including the quotes. If someone else updated the event after you read it, the API rejects the request with `RESOURCE_CONFLICT` (`409 Conflict`). In that case, read the event again, decide what to do, and retry the request. ```bash curl -X PATCH "https://api.pyas.io/v2/events/$EVENT_ID?accountId=$ACCOUNT_ID" \ -H "x-api-key: $PYAS_API_KEY" \ -H "If-Match: $VERSION" \ -H "content-type: application/json" \ -d '{ "title": "Renamed" }' ``` ## Keeping a copy in sync To keep a copy of a calendar in your own database, start a sync for a time window by passing `sync=true`. The API returns every event, page by page, and the last page includes `meta.syncToken`. Later, send only `syncToken` to get the changes since that sync. Deleted events are listed in `meta.deletedIds`. Use the `event.*` [webhooks](/webhooks) to find out when to sync again. ## What else events can do The [Events reference](/reference/events) covers everything else you can do with events. This includes attachments (links and files), responding to invitations (with a proposed new time on Outlook), moving an event to another calendar, forwarding an event, reminders, creating an event from a sentence (quick add), and importing events. # Webhooks ::lead 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: ::code-group ```js [Node.js] const { endpoint, signingSecret } = await pyas.webhooks.create({ url: 'https://yourapp.com/webhooks/pyas', events: ['event.*', 'connection.broken'], }) ``` ```bash [cURL] curl -X POST https://api.pyas.io/v2/webhooks \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "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: ```http GET /v2/webhooks/{webhookId}/secret ``` ## Verify every delivery Each delivery includes a `Pyas-Signature` header in the format `t=,v1=`. The `v1` value is an HMAC-SHA256 of `.`, 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. ::code-group ```js [Node.js] 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) }) ``` ```js [Node.js (any framework)] import { createHmac, timingSafeEqual } from 'node:crypto' function verify(header, rawBody, secret) { const parts = Object.fromEntries(header.split(',').map((p) => p.trim().split('='))) if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex') return header.split(',').some((p) => { const given = Buffer.from(p.trim().slice(3)) return p.trim().startsWith('v1=') && given.length === expected.length && timingSafeEqual(given, Buffer.from(expected)) }) } ``` :: To rotate an endpoint's signing secret, call: ```http 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 ```json { "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](#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: | Field | What it is | |---|---| | `id` | The event's ID. When `isSeries` is `true`, the ID of the recurring series. | | `calendarId` | The calendar the event is on. | | `seriesId` | For one occurrence of a recurring series that changed on its own, the series' ID. Otherwise `null`. | | `isSeries` | `true` 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`: ```http GET /v2/events/6v1q8b3k2m9t0c4p7r5s?accountId={accountId}&calendarRef=primary ``` When `isSeries` is `true`, the whole series changed. To refresh its occurrences, list them: ```http 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: ```http 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 | Type | When | |---|---| | `connection.created`, `connection.reauthorized` | A user connected an account, or reconnected a broken connection | | `connection.broken`, `connection.deleted` | A connected account can no longer sync, or was deleted | | `calendar.created`, `calendar.updated`, `calendar.deleted` | A calendar was created, updated, or deleted | | `event.created`, `event.updated`, `event.deleted` | An event was created, updated, or deleted, through the API or in a calendar app | | `invitation.replied` | Someone responded to an invitation sent from a Pyas calendar | | `meeting.started`, `meeting.ended`, `meeting.updated`, `meeting.deleted` | A Zoom meeting started, ended, was updated, or was deleted | | `meeting.participant_joined`, `meeting.participant_left` | Someone joined or left a meeting | | `recording.ready` | A meeting's recording is ready to fetch | | `api_key.expiring`, `api_key.revoked` | An API key is about to expire, or was revoked | | `webhook.ping` | A test delivery | To get every event type with a sample payload for each, call: ```http 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: ```http 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" }`: ```http 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: ```http 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. ```http 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: ```bash 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: ```http 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](#verify-every-delivery) 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](/migrating-from-v1). # Pagination ::lead Every list endpoint returns one page of results in `data`. When more results are available, the response also includes a cursor in `meta.nextCursor`. To get the next page, send that value as the `cursor` parameter. On the last page, `nextCursor` is `null`. :: ```json { "success": true, "data": [ { "id": "..." }, { "id": "..." } ], "meta": { "nextCursor": "cur_4f2a9c1e7b3d4a6f8e0c2b1d5a7c9e3f" }, "provider": "google", "traceId": "trc_..." } ``` ::code-group ```js [Node.js] // Every event in the window, across every page: for await (const event of pyas.events.list({ accountId, from, to })) { console.log(event.title) } // Or one page at a time: const page = await pyas.events.list({ accountId, from, to, limit: 50 }) const next = await page.next() // null after the last page ``` ```bash [cURL] curl "https://api.pyas.io/v2/events?accountId=$ACCOUNT_ID&from=2026-10-20T00:00:00Z&to=2026-11-20T00:00:00Z&limit=50" \ -H "x-api-key: $PYAS_API_KEY" # Then request the next page with meta.nextCursor from the previous response: curl "https://api.pyas.io/v2/events?accountId=$ACCOUNT_ID&from=2026-10-20T00:00:00Z&to=2026-11-20T00:00:00Z&limit=50&cursor=cur_..." \ -H "x-api-key: $PYAS_API_KEY" ``` :: ## Rules - When you send a cursor, send the same query parameters that you sent for the first page. Only `limit` can be different. A cursor that Pyas generates (`cur_...`) remembers its query, and the API rejects it with a different one. Other cursors don't, so a different query returns the wrong page. - You can reuse a cursor. The same cursor always returns the same page, so it's safe to retry a request. - Cursors that Pyas generates (`cur_...`) are valid for 1 day, so don't store them. To continue later, start again from the first page. - `limit` sets the page size. Each list endpoint has its own default and maximum: | List | Default | Maximum | |---|---|---| | Events, occurrences | 100 | 250 | | Events by metadata (`metadata.=`) | 50 | 100 | | Accounts | 50 | 200 | | Webhook events and deliveries | 50 | 100 | | Request logs | 50 | 100 | A page can contain fewer items than `limit` and still have a next page, because filters can exclude some items from a page. Keep requesting pages until `nextCursor` is `null`. ## Order Accounts are returned in the order they were added. Webhook events, deliveries and request logs are returned newest first. Events from a single calendar are returned in the provider's order. To sort them by start time, use `expand=true` with `sort=start`. To sort them by last update, use `sort=updated`. Events from several accounts are returned as a single timeline, sorted by start time. To track changes to a calendar instead of paging through it again, use a [sync token](/events#keeping-a-copy-in-sync). # Retries and idempotency ::lead If a network error occurs, you might not receive a response. For example, if a create request times out, you can't tell whether the resource was created. To retry safely, send an `Idempotency-Key` header and retry with the same key. The API returns the original response and doesn't create anything twice. :: ```bash curl -X POST https://api.pyas.io/v2/events \ -H "x-api-key: $PYAS_API_KEY" \ -H "Idempotency-Key: booking-81723" \ -H "content-type: application/json" \ -d '{ "accountId": "'$ACCOUNT_ID'", "title": "Intro call", "startsAt": "2026-10-21T15:00:00Z", "endsAt": "2026-10-21T15:30:00Z" }' ``` The SDK does this for you on every `POST`, `PATCH`, `PUT` and `DELETE` request. It generates a key and reuses it when it retries the request. To tie retries to your own records, pass your own key with the `idempotencyKey` option: ```js pyas.events.create(body, { idempotencyKey: 'booking-81723' }) ``` ## How it works - Any `POST`, `PATCH`, `PUT` or `DELETE` request accepts an `Idempotency-Key` header. A key can be up to 255 printable characters and must be unique for each operation you intend to perform. For example, you can use a booking id or a random UUID. - The API stores the response for 24 hours. If you send the same key with the same request, the API returns the stored response with an `Idempotent-Replayed: true` header. - If you send the same key with a different request, the API rejects the request with `VALIDATION_IDEMPOTENCY_CONFLICT` (`422 Unprocessable Entity`), because each key identifies a single operation. - If you send the same key while the first request is still in progress, the API rejects the request with `RESOURCE_CONFLICT` (`409 Conflict`). Wait a moment, then retry. - The API doesn't store responses for failures that are worth retrying (5xx and `429 Too Many Requests`), so a retry runs the request again. - Keys are scoped to your app, so a key sent by another app never conflicts with yours. ## What to retry Every error code indicates whether a retry can succeed. The [error codes](/errors) page lists the `retryable` value for each code. You can also get these values from the API: ```http GET /v2/errors ``` | Response | Retry? | |---|---| | 429 `RATE_LIMIT_EXCEEDED`, `PROVIDER_RATE_LIMITED` | Yes, after `Retry-After` seconds | | 5xx, `PROVIDER_TIMEOUT`, `PROVIDER_UNAVAILABLE`, `CONNECTION_TOKEN_EXPIRED` | Yes, with backoff. Include an idempotency key for writes. | | `PROVIDER_UNKNOWN_ERROR` (502) | Once. If it fails again, contact support with the trace id. | | No response | Yes, with the same idempotency key | | Any other 4xx | No. Change the request first. The error's hint explains how. | Increase the wait between attempts (1, 2, 4, 8 seconds, with some random jitter), and stop after a few attempts. ## Prevent conflicting updates Idempotency protects you from duplicates caused by your own retries. To avoid overwriting an update that someone else made, send the event's version in the `If-Match` header. See [Prevent conflicting updates](/events#prevent-conflicting-updates). # Rate limits ::lead Each API key can make a set number of requests in any 5-minute window, depending on your plan. v1 and v2 requests count toward the same limit. :: | Plan | Requests per 5 minutes | About per minute | |---|---|---| | Developer | 100 | 20 | | Starter | 1,500 | 300 | | Growth | 3,000 | 600 | | Scale | 5,000 | 1,000 | Every response to a request with a valid key includes headers that show your current usage: | Header | Description | |---|---| | `RateLimit-Limit` | The number of requests allowed in the window | | `RateLimit-Remaining` | The number of requests remaining in the window | | `RateLimit-Reset` | Seconds until the window resets | For older clients, `X-RateLimit-Limit` and `X-RateLimit-Remaining` contain the same values, and `X-RateLimit-Reset` contains the time of the reset as a Unix timestamp. ## When you exceed the limit When you exceed the limit, the API responds with `429 Too Many Requests`, the error code [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED), and a `Retry-After` header that contains the number of seconds until the window resets. Wait that long before you send more requests. The SDK waits and retries for you automatically. ## Provider limits Google, Microsoft, and Zoom limit how often you can make requests for a single account, regardless of your Pyas plan. When a provider rate-limits a request, the API responds with [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) and a `Retry-After` header. The limit applies only to that account, and requests for your other accounts aren't affected. ## Make fewer requests - Use [webhooks](/webhooks) to get notified when something changes, so you don't need to poll for changes. - Use a [sync token](/events#keeping-a-copy-in-sync) to get only what changed since your last read. - Use `fields=id,title,startsAt` to get only the fields you need. - Read several accounts in one request with `accountIds` or `subjectId`. This counts as one request, not one per account. ## Monthly requests Requests made with a live key also count toward your plan's monthly quota. When you exceed it, the API responds with [`RATE_MONTHLY_QUOTA_EXCEEDED`](/errors/RATE_MONTHLY_QUOTA_EXCEEDED) until the next month starts or your plan changes. Requests made with a test key have a separate monthly quota. The **Billing** page in the dashboard shows your requests so far this month. To get your requests by day, call: ```http GET /v2/usage ``` # Errors ::lead Every error response has the same format. It includes a stable `code` that your code can check, a `message` that describes what went wrong, a `hint` that explains how to fix it, a link to the error code's page, and the `traceId` that identifies the request in your logs. :: ```json { "success": false, "error": { "code": "CONNECTION_NOT_FOUND", "message": "No connected account was found for this request.", "hint": "The user may not have connected this provider yet. Start a connection with PyasConnect.", "status": 404, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "docs": "https://developer.pyas.io/errors/CONNECTION_NOT_FOUND" } } ``` - Check `code` in your error handling, never `message`. Messages are written for people and can be improved over time, but codes don't change. - Write `hint` to your logs, where developers can read it. - Keep the `traceId`. Include it when you contact support so that we can help you faster. To see what the request did, how the provider responded and why the request failed, call the endpoint below or run `pyas logs get trc_...`: ```http GET /v2/logs/{traceId} ``` The error codes are also available as data, for use in your tooling and your coding agent. This request returns every code with its status, causes, fix, whether retrying helps, and related codes: ```http GET https://api.pyas.io/v2/errors ``` ## Status codes | Status | Meaning | |---|---| | `400 Bad Request` | A parameter or the body is invalid. The message identifies the field. | | `401 Unauthorized` | The API key is missing, wrong, revoked or expired. | | `402 Payment Required` | Your plan needs attention: no subscription, an overdue payment, or the plan's connected account limit is reached. | | `403 Forbidden` | The key isn't allowed to perform this action, because of its scopes, its mode (live or test) or your plan. | | `404 Not Found` | The account, calendar or event doesn't exist for your app. | | `409 Conflict` | Someone updated the resource after you read it, the same request is still in progress, or a sandbox limit is reached. | | `410 Gone` | The sync token has expired. Read the calendar again without one. | | `413 Content Too Large` | The body or the uploaded file is too large. | | `422 Unprocessable Entity` | The request is valid but can't be completed: the provider doesn't support it or rejected it, the account must be reconnected, or an `Idempotency-Key` was reused for a different request. | | `429 Too Many Requests` | You've sent too many requests. Wait the number of seconds in `Retry-After`. | | `5xx` | Pyas or the provider failed. Retry with backoff. | ## Every code ::error-codes :: # Migrating from v1 ::lead 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 `accountId`s | 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`](/errors/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](#webhooks). | | v1 query parameters (`calendarId`, `timeMin`, `startDate`...) | Accepted, and listed in a `Pyas-Deprecated-Fields` header ([see the full list](#v1-parameters-that-v2-still-accepts)) | | 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](#extended-properties)). | These are the v1 endpoints for connecting accounts: ```http GET /{provider}/oauth POST /{provider}/connect ``` In v2, the same two steps are these endpoints. See [Connecting accounts](/connect#option-2-your-own-screens). ```http 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](/events) 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](/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](/pagination). The API still accepts a v1 `nextPageToken` from a Google response. **Test keys.** v2 has a [sandbox](/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](/events#your-own-data-on-events) 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: ```http 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: ```bash curl "https://api.pyas.io/v2/events/EVENT_ID?accountId=ACCOUNT_ID&include=extendedProperties" \ -H "x-api-key: $PYAS_API_KEY" ``` ```json { "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: ```bash 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" ``` ```json { "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. ```bash 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 }' ``` ```json { "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": [] } } ``` 2. Run the import. Send the same body without `dryRun`. The response is `202 Accepted` with an `importId`, and the import runs in the background. ```bash 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" }' ``` 3. Check its progress with `GET /v2/metadata/imports/{importId}` until `status` is `done`. `counts.copied` is the number of events that received metadata. 4. 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: ```json "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-migration{show="calls"} :: Zoom's meeting endpoints (`/zoom/meetings`) are [`/v2/meetings`](/reference/meetings) in v2. Deleting a connected account is [`DELETE /v2/accounts/{accountId}`](/reference/accounts) in v2. Deactivating one is still available only in v1: ```http 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: ```http 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. ::v1-migration{show="names"} :: ## 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: ```http 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](/webhooks#verify-every-delivery), 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](/webhooks#accept-both-formats-at-one-url): 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](#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. # AI in your app ::lead If your app has an AI assistant, `@pyas/ai-tools` gives it calendar tools. It can see what's on someone's week, find a time when they're free, and book, move or cancel a meeting. Each person's assistant works only with their own calendars, and it asks them before it changes anything. :: This page is about the assistant inside your product, used by your customers. To use Pyas yourself, from ChatGPT, Claude or your code editor, see [AI tools](/ai-tools). ## How it works 1. Your users connect their Google or Outlook calendars with [PyasConnect](/connect). Pyas saves each account under your own ID for that user, the `subjectId`. 2. On your server, you give the model Pyas's tools for the signed-in user. The tools have access to that user's accounts only. 3. The model reads the calendars when it needs to. When it wants to change something, your app shows the person the change, and the change runs only if they approve it. You bring the model. Pyas provides the calendar data and the actions, and your API key stays on your server. ## Choose your framework Each guide has the complete code for a chat that books meetings, tested with a real model: - [Vercel AI SDK](/vercel-ai-sdk): a Next.js chat with `useChat` - [OpenAI](/openai): Chat Completions or the Responses API - [Claude API](/claude-api): the Messages API - [LangChain](/langchain): an agent made with `createAgent` Install the package in your server code: ```bash npm install @pyas/ai-tools ``` The tools read your key from `PYAS_API_KEY`. ## The tools | Tool | What it does | Asks the person first | |---|---|---| | `list_accounts` | Lists the person's connected calendars | | | `account_status` | Checks whether a calendar is working | | | `list_calendars` | Lists the calendars in an account | | | `list_events` | Lists events between two times | | | `get_event` | Gets one event with all its details | | | `find_meeting_times` | Finds times when everyone is free | | | `get_free_busy` | Gets busy times | | | `create_event` | Creates an event, with a Meet, Teams or Zoom link if asked | Yes | | `update_event` | Changes an event, or adds or removes its video meeting | Yes | | `delete_event` | Deletes an event | Yes | | `respond_to_event` | Accepts or declines an invitation | Yes | Events come back with their local times, so the model tells people the time where the event is. ## One person's calendars Pass the signed-in person's ID as `subjectId`: ```ts pyasTools({ subjectId: user.id }) ``` The tools then have access to that person's accounts only. The model can't choose another person. A request for anyone else's account is refused before it reaches Pyas, with the code `TOOL_ACCOUNT_NOT_ALLOWED`. Take the ID from your own sign-in, on your server. Never take it from the request body or from what the model says. `subjectId` is required. Without it, or with an empty one (a session with no user), building the tools throws an error instead of quietly giving the model every user's calendars. For an agent that works for you rather than for one of your users, such as an internal assistant, pass `allAccounts: true` to reach every account in the app. Never use it for a chat your users open. When a change waits for approval, the tools also return the calendar it lands on (`calendar`: its email, provider and `subjectId`). Show it on the approval card, so someone with several calendars sees which one. ## Which calendar People often connect more than one calendar, such as work Google and personal Outlook. When they ask for a new event without saying where, the model asks them which calendar to use. If your app lets people choose a default calendar, pass it on each request as a hint: ```ts pyasTools({ subjectId: user.id, defaultAccountId: user.defaultCalendarId }) ``` Pyas doesn't store it: keep it with the user in your own database. With it, the model uses that calendar without asking unless the person names another, and it's checked like any other account, so one that isn't the person's is refused. Empty or `null` means no default. ## Changes wait for approval A tool that changes a calendar doesn't run when the model asks for it. Your app shows the person the change, and they approve or decline it: ::screenshot{src="/images/approval-in-your-app.png" alt="A chat in an app. The person asked to move their 1:1 with Sarah to 4:30 PM. A card shows the event's current time and the new time, with Approve and Decline buttons." width="660" height="399" caption="An approval in a chat built with the Vercel AI SDK guide. The event's title and current time come from Pyas."} :: - **What the person sees comes from Pyas.** For a change to an existing event, you get the event as it is now in Pyas, not as the model describes it. Show it. If text in an invitation tricks the model into picking another event, the person sees which event it really is. - **A change that can never run isn't put to the person.** A change to someone else's account, or to an event that doesn't exist, is refused immediately, and the model is told why. - **A declined change is never sent to Pyas.** The model is told that the person declined, and it leaves the calendar as it is. Each guide shows how this works in its framework. If your app already asks the person before a change, turn this off with `requireApproval: false`. ## Offer fewer tools Give the assistant only what it needs: ```ts pyasTools({ subjectId: user.id, readOnly: true }) // reads only: nothing can change pyasTools({ subjectId: user.id, scope: ['events', 'availability'] }) // events and free times only pyasTools({ subjectId: user.id, scope: ['events:read'] }) // list and get events only ``` The scopes are `accounts`, `calendars`, `events` and `availability`, each alone or with `:read` or `:write`. `read` and `write` alone apply to all four. A scope that doesn't exist throws an error, so a typo never gives the assistant more than you meant. ## When a tool fails A tool never throws an error at the model. It returns the error with its code, a hint and the request's trace ID, so the model can fix its request or tell the person what to do: ```json { "error": { "code": "RESOURCE_NOT_FOUND", "message": "The calendar or event was not found.", "hint": "Check the ID. The item may have been deleted, or it may not be visible to this account.", "docs": "https://developer.pyas.io/errors/RESOURCE_NOT_FOUND", "traceId": "trc_..." } } ``` Every request the tools make is on the **Logs** page in the dashboard, marked **By @pyas/ai-tools**. To see what a request did, open its trace ID there. ## Try it in the sandbox Start with a test key. A test key works only in your app's [sandbox](/sandbox), where sample people have a week of events, and nothing reaches a real calendar. 1. Copy a test key from **API keys** in the [dashboard](https://app.pyas.io), and set it as `PYAS_API_KEY` on your server. 2. Use the sandbox person Avery Brooks as the signed-in user: `subjectId: 'sandbox_user_1'`. 3. Ask: "What's on my calendar tomorrow?", then "Book a 30 minute meeting tomorrow afternoon when I'm free." ## Go live 1. Add [PyasConnect](/connect) to your app, so people can connect their calendars. Save each account under your user's ID as `subjectId`. 2. Pass the signed-in user's ID as `subjectId`, in place of the sandbox person. 3. Create a live key in the dashboard, and set it as `PYAS_API_KEY` on your server. With a live key, the assistant reads and changes real calendars, and every change still waits for the person's approval. # AI tools ::lead The Pyas MCP server lets an AI tool set up your app and work in its sandbox or on live calendars. You add it to the tool by its address and sign in to Pyas from the tool. There is nothing to install and no key to copy. :: The server has two addresses, as a key is a test key or a live key: | Address | What the tool works on | |---|---| | `https://mcp.pyas.io/sandbox` | The app's sandbox: test people, calendars and events | | `https://mcp.pyas.io` | The live calendars that people connected to the app | Start with the sandbox. To use both, add each as its own server (see [Live calendars](#live-calendars)). ## Add the server Each tool has its own guide, from adding the server to your first request: | Where you work | Guide | |---|---| | A chat | [ChatGPT](/chatgpt), [Claude](/claude) | | Your code editor or terminal | [Cursor](/cursor), [VS Code](/vs-code), [Claude Code](/claude-code), [Codex](/codex) | Any other tool that adds an MCP server by its address and signs in with OAuth works the same way: enter `https://mcp.pyas.io/sandbox` and sign in when it asks. ## Sign in When the tool connects, your browser opens the Pyas dashboard: 1. Sign in, or create an account. 2. Choose the app the tool works with. A tool works with one app. To use it with another app, disconnect it and connect it again. 3. Check what the tool asks for. On the live address, choose whether it may only read live calendars or change them too. 4. Select **Allow**. ::screenshot{src="/images/sign-in-sandbox.png" alt="The Pyas page that asks: Allow Claude Code to use Acme Calendar Sync? It shows the tool, the app with a Change link, the sandbox as the calendars, and what the tool will be able to do, with Allow and Deny buttons." width="551" height="921" caption="Signing in on the sandbox address. The page names the tool, the app and the calendars, and lists what the tool will be able to do."} :: A tool can ask for these permissions: | Permission | What the tool can do | |---|---| | Use this app's sandbox | Read and change the sandbox's test people, calendars and events | | Set up this app | Change the app's settings and allowed origins, and add sandbox webhooks | | Create test keys | Create test keys, which work only in the sandbox | | Read live calendars | Read the accounts and events that people connected to the app | | Change live calendars | Create, update and delete events and reply to invitations, each change confirmed by you | | Stay connected | Stay connected until you disconnect it | ## What the tool can do On the sandbox address, the tool has the calendar tools of [`@pyas/mcp`](/sdks#mcp-server): list accounts and events, find a time when everyone is free, and create, update and delete events. It also has the tools for debugging your integration, such as `explain_error` and `get_request`. Every request works only in the sandbox and doesn't affect real calendars. With the setup permissions, the tool can set up the app: | Tool | What it does | |---|---| | `setup_status` | Shows what is done in the app's setup, what is left, and the next step | | `update_app` | Adds the address your app runs at, such as a preview URL, to its allowed origins | | `get_test_key` | Creates a test key, and tells you where to store it as a secret | | `create_webhook` | Adds a sandbox webhook endpoint, returns its signing secret and sends a test delivery | | `create_app` | Creates another app | | `go_live_checklist` | Lists what is left before the app works with real calendars | | `get_recipe` | Returns the code for a task on your platform, with where each file goes and the secret to set | Some things are always done by you, in the dashboard: choosing a plan, creating live keys and adding live webhooks. The tool gives you the link when it gets there. Event titles, descriptions and attendees are written by other people. The server marks that text as data in every response, so the tool doesn't follow instructions that someone puts in an invitation. ## Live calendars A tool can also work on the live calendars that people connected to your app, for example to find out why a booking didn't show up. Add a second server with the live address: 1. Add `https://mcp.pyas.io` the same way as the sandbox, under its own name, such as `pyas-live` or `Pyas live`. Each tool's guide shows how. 2. [Sign in](#sign-in). The page asks **What may it do with live calendars?**: - **Read only**: the tool can list accounts and events, but can't change anything, including the app's setup. - **Read and change**: the tool can also create, update and delete events and reply to invitations. ::screenshot{src="/images/sign-in-live.png" alt="The Pyas sign-in page on the live address, with live calendars as the calendars and the question What may it do with live calendars?, with Read and change chosen." width="551" height="1127" caption="On the live address, you choose what the tool may do with live calendars. Allow stays off until you choose."} :: Each address works on its own calendars: the sandbox address works only in the sandbox, and the live address only on live calendars. The description of every tool starts with **Sandbox:** or **Live calendars:**, so the AI can tell the two servers apart, and it asks you when it isn't clear which one you mean. While you work in the sandbox, turn the live server off, so the tool can't mix up the two. Each tool's guide shows how. Requests on live calendars count toward your plan's monthly requests, as a live key's do. Changing live calendars needs the owner, admin or developer role in the organization. ### Confirm each change Before a tool changes a live calendar, you confirm the change in Pyas: 1. The tool asks to make the change and gives you a link. Some tools open the page for you. 2. The page shows the app, the calendar and what would change. Select **Confirm** or **Decline**. ::screenshot{src="/images/confirm-change.png" alt="The Pyas page Claude Code asks to create an event, showing the app, the calendar, the title and the start and end times, with Confirm and Decline buttons." width="551" height="894" caption="A change waiting for you. It shows the calendar it acts on and exactly what would change."} :: 3. Go back to the tool and let it carry on. It makes the change when it tries again. Only the person who connected the tool can confirm its changes. A link works for 15 minutes. Changes to the app are confirmed the same way. On the live address, you confirm every change to the app. On either address, you confirm each new allowed origin, because live calendars use the same list. **Don't ask again** doesn't cover changes to the app. To stop being asked for a while, select **Don't ask again for this tool for the next hour** before you confirm. **Connected tools** on your profile shows when the hour ends, and **Ask each time** ends it early. ## Disconnect a tool Your connected tools are listed on your profile in the dashboard, under **Connected tools**, with when each was last used. Select **Disconnect** to stop a tool immediately. ::screenshot{src="/images/connected-tools.png" alt="The Connected tools card on the profile page, listing Claude Code with its app, its access (App setup, Live calendars, Live changes), when it connected and was used, and a Disconnect button." width="768" height="259" caption="Connected tools on your profile. Access shows what each tool may do."} :: Test keys and sandbox webhooks that the tool made keep working, so anything it built keeps running. You can revoke them on the app's pages. To use it again, connect it again from the tool. The **Logs** page in the dashboard shows which tool made each request, such as "By Claude (AI tool)". ## The setup endpoints The MCP server and the CLI's `pyas login` set up apps through these endpoints. You can call them yourself with a `pyas login` token, or with the SDK's `pyas.manage`. A connected tool's own token doesn't work here: it works only through the MCP server. Once an app has connected calendars, change its allowed origins in the dashboard, not with a `pyas login` token. Every path starts with `/v2/manage/apps`: | Method | Path | What it does | |---|---|---| | `GET` | | Lists the apps you can set up | | `POST` | | Creates an app | | `GET` | `/{appId}` | Returns the app, its setup checklist and the next step | | `PATCH` | `/{appId}` | Updates the app's name, description, website and allowed origins | | `GET` | `/{appId}/webhooks` | Lists the app's sandbox webhook endpoints | | `POST` | `/{appId}/webhooks` | Adds a sandbox webhook endpoint and returns its signing secret | | `POST` | `/{appId}/webhooks/{webhookId}/test` | Sends a test delivery | | `GET` | `/{appId}/keys` | Lists the app's keys, without their values | | `POST` | `/{appId}/keys/test` | Creates a test key and returns its value | To create or change anything, send a person's access token: `Authorization: Bearer `. `pyas login` gets one for you. An API key in `x-api-key` can only read its own app's setup. ```bash npx @pyas/cli login npx @pyas/cli apps show ``` For example, the setup of an app: ```http GET /v2/manage/apps/5c1e9a47-2d3b-4f86-a0e9-7b4c2d18f365 Authorization: Bearer ``` ```json { "success": true, "data": { "appId": "5c1e9a47-2d3b-4f86-a0e9-7b4c2d18f365", "orgId": "a3d97f2c-6e41-4b8a-9c05-1f7e2b4d8a60", "name": "Booking page", "description": "", "websiteUrl": "", "allowedOrigins": ["https://booking.example.com"], "signIn": { "google": "pyas", "microsoft": "pyas", "zoom": "pyas" }, "setup": { "steps": [ { "id": "allowed_origins", "done": true, "text": "Add the address your app runs at (its preview or production URL) to the allowed origins, so PyasConnect opens there." }, { "id": "first_call", "done": false, "text": "Make a first request against the sandbox with a test key." }, { "id": "test_key", "done": false, "text": "Create a test key and store it as a secret in your project." }, { "id": "webhook", "done": false, "text": "Add a webhook endpoint, so your app hears when calendars change." }, { "id": "own_calendar", "done": false, "text": "Connect your own calendar in Live from the dashboard, and call the API on it." }, { "id": "signin", "done": true, "text": "Google, Microsoft and Zoom use Pyas Auth." }, { "id": "live", "done": false, "text": "Create a live key in the dashboard and store it as a secret where your server code runs, then connect a real calendar account." } ], "nextStep": "Make a first request against the sandbox with a test key." }, "dashboardUrl": "https://app.pyas.io/org/apps/5c1e9a47-2d3b-4f86-a0e9-7b4c2d18f365" }, "provider": null, "traceId": "trc_7f3a9c2e1b4d6a8f0c5e2b1d", "meta": {} } ``` # Build with Base44 ::lead Base44's AI can add Google Calendar and Microsoft Outlook to your app with Pyas. You connect the Pyas MCP server to Base44 once. The AI then gets the code for each task from Pyas, and you store a key as a secret. The key stays in backend functions, and the code works with the sandbox's sample people before anyone connects a real calendar. :: ## Before you start Base44 adds custom MCP servers and backend functions on its **Builder** plan and above. Both are needed here. Base44's MCP tools work only in the editor chat, while you build. What the AI reads from Pyas there is written into your app as it was at that moment, and doesn't update in the published app. That's why the code Pyas gives the AI reads calendars from backend functions, which call Pyas each time your app needs the data. ## Add Pyas to Base44 1. Select your workspace name at the bottom left, then **Account preferences**, then **Builder**. 2. Under **MCP Connections**, select **Add custom MCP**. 3. For **Name**, enter `Pyas`. For **URL**, enter `https://mcp.pyas.io/sandbox`. For **Authentication**, choose **OAuth**. 4. Select **Test**, then **Test & Add**. 5. Authorize the connection: sign in to Pyas, choose your app, and select **Allow**. See [Sign in](/ai-tools#sign-in) for what each permission allows. The connection is yours, and it works in every app you build in Base44. New tools from Pyas can take up to 15 minutes to appear. ## Ask for what you want Ask in plain words, for example: > Add a button that lets people connect their Google or Outlook calendar, and show their events for the next week. The AI checks your setup with `setup_status` and gets the code for each task with `get_recipe`. It adds your app's address, such as `https://your-app.base44.app`, to the allowed origins with `update_app`, and creates a test key with `get_test_key`. You confirm the address in Pyas, from the link it gives you. Every response from Pyas ends with the next step, so the AI knows what to do next. ## Store the key Store the test key as a secret named `PYAS_API_KEY`: 1. In the app editor, open **Dashboard**, then **Secrets**, and select **Add Secret**. 2. Enter `PYAS_API_KEY` as the **Secret Name** and the key as the **Secret Value**, then select **Add Secret**. The **Secrets** page appears once your app has a backend function. Don't paste the key into the chat. Backend functions read the secret on the server, and it never reaches the browser. A test key works only in your app's [sandbox](/sandbox). ## What Base44 adds The code runs as backend functions: ```text base44/functions/pyasConnectSession/entry.ts creates a connect session for the button base44/functions/pyasEvents/entry.ts the person's events for the next 7 days base44/functions/pyasBook/entry.ts books the first free 30 minutes tomorrow src/components/ConnectCalendar.jsx the "Connect an account" button ``` Your pages call each function by its name, such as `pyasEvents`, with the app's Base44 client and `base44.functions.invoke`. Each function reads the key with `secrets.get('PYAS_API_KEY')`, and the signed-in person with Base44's `auth.me()`. With a test key, the functions use the sandbox's sample person, so the events and booking work right away. The connect button shows a message from Pyas that connecting a real calendar requires a live key. This is expected. After you add a function or change a secret, publish the app again. The published app uses the functions and secrets it had when you last published it. ## Receive webhooks Ask the AI to receive Pyas webhooks. It adds a `pyasWebhooks` function, which verifies the signature of each delivery against the raw body before it reads the event. The AI adds the endpoint to your sandbox with `create_webhook`, at your published address: ```text https://.base44.app/functions/pyasWebhooks ``` Store the signing secret that `create_webhook` returns as a secret named `PYAS_WEBHOOK_SECRET`. See [Webhooks](/webhooks) for the events Pyas sends. ## Go live 1. In the Pyas dashboard, choose a plan and create a live key. Plans, payment and live keys are always done by you, in the dashboard. 2. Replace the `PYAS_API_KEY` secret with the live key, and publish the app again. 3. The functions now use the person signed in to your Base44 app. People who aren't signed in get a `401` response with the code `SIGN_IN_REQUIRED`, so your app needs sign-in before people connect a calendar. 4. Add your published address to the app's allowed origins, and add a live webhook endpoint in the dashboard. ## Disconnect In Base44, open **Account preferences**, then **Builder**, then **MCP Connections**. Turn Pyas off to pause it, or open **More actions**, then **Remove MCP**, to remove it. To stop Base44 from Pyas's side, open your profile in the Pyas dashboard. **Connected tools** lists Base44 with when it was last used, and **Disconnect** stops it immediately. # Build with Bolt ::lead Bolt's agent can add Google Calendar and Microsoft Outlook to your app with Pyas. You connect the Pyas MCP server to Bolt once. The agent then gets the code for each task from Pyas, and you store a key as a secret. The key stays on the server, and the code works with the sandbox's sample people before anyone connects a real calendar. :: ## Add Pyas to Bolt 1. On the Bolt home page, select the **+** icon in the chat box, then **Connectors**, then **Manage connectors**. 2. Select **Custom MCP server**. 3. For **Name**, enter `Pyas`. For **URL**, enter `https://mcp.pyas.io/sandbox`. Set **Transport type** to **HTTP** and **Authentication** to **MCP OAuth**. 4. Select **Connect**. Sign in to Pyas, choose your app, and select **Allow**. See [Sign in](/ai-tools#sign-in) for what each permission allows. 5. Turn the connector on for your project: select **+**, then **Connectors**, and turn on **Pyas**. To turn it on for every new project, use **Auto-enable for all projects**. Only a project's owner can connect MCP servers to it. To remove Pyas, open **Manage connectors**, select **Pyas**, then the three dots, then **Delete**. ## Ask for what you want Ask in plain words, for example: > Add a button that lets people connect their Google or Outlook calendar, and show their events for the next week. The agent checks your setup with `setup_status` and gets the code for each task with `get_recipe`. It adds your app's preview address to the allowed origins with `update_app`, and creates a test key with `get_test_key`. You confirm the address in Pyas, from the link it gives you. Every response from Pyas ends with the next step, so the agent knows what to do next. ## Store the key Store the test key as a secret named `PYAS_API_KEY`: - **In Bolt:** select the database icon, then **Secrets**. Enter the name and the key, and select **Save**. The project needs a database first, and only its owner can add secrets. - **In a project that uses your own Supabase project:** add it in the Supabase dashboard, under your project's **Edge Functions** secrets. Don't put the key in the chat or in a `VITE_` variable: those reach the browser. A test key works only in your app's [sandbox](/sandbox). ## What Bolt adds The code is written as Supabase Edge Functions, for projects that use Supabase: ```text supabase/functions/_shared/pyas.ts the Pyas client, shared by the functions supabase/functions/pyas-connect-session/index.ts creates a connect session for the button supabase/functions/pyas-events/index.ts the person's events for the next 7 days supabase/functions/pyas-book/index.ts books the first free 30 minutes tomorrow src/components/ConnectCalendar.tsx the "Connect an account" button ``` The button imports the project's Supabase client, so the agent points that import at the file where your project creates it. Your pages call the functions with that client, for example `supabase.functions.invoke('pyas-events')`. With a test key, the functions use the sandbox's sample person, so the events and booking work right away. The connect button shows a message from Pyas that connecting a real calendar requires a live key. This is expected. ## Receive webhooks Ask the agent to receive Pyas webhooks. It adds `pyas-webhooks/index.ts` to your functions, and turns off the Supabase sign-in check for that function in `supabase/config.toml`, because the function verifies the signature of each delivery instead: ```toml [functions.pyas-webhooks] verify_jwt = false ``` The agent adds the endpoint to your sandbox with `create_webhook`, at the function's public address. In Bolt, the address is listed under the database icon, in **Server Functions**. Store the signing secret that `create_webhook` returns as a secret named `PYAS_WEBHOOK_SECRET`. See [Webhooks](/webhooks) for the events Pyas sends. ## Go live 1. In the Pyas dashboard, choose a plan and create a live key. Plans, payment and live keys are always done by you, in the dashboard. 2. Replace the `PYAS_API_KEY` secret with the live key. 3. The functions now use the signed-in Supabase user. People who aren't signed in get a `401` response with the code `SIGN_IN_REQUIRED`, so your app needs sign-in before people connect a calendar. 4. Add your published address, such as `https://your-app.bolt.host`, to the app's allowed origins, and add a live webhook endpoint in the dashboard. # ChatGPT ::lead Add Pyas to ChatGPT, and you can ask about your app's calendars in a chat: who is free tomorrow, what is on someone's week, or book a meeting with a video link. You add Pyas by its address and sign in. There is nothing to install and no key to copy. :: Start with the sandbox address, `https://mcp.pyas.io/sandbox`. It works in your app's [sandbox](/sandbox), on sample people, calendars and events, so nothing you try reaches a real calendar. ## Add Pyas to ChatGPT You add Pyas on chatgpt.com, as a custom plugin. 1. Go to [chatgpt.com/plugins](https://chatgpt.com/plugins). 2. Select **+**, then **Add custom MCP server**. 3. Enter the name `Pyas sandbox` and the address `https://mcp.pyas.io/sandbox`. For authentication, choose **OAuth**. 4. Select **Create as a plugin**. 5. Select **Connect Pyas sandbox**, then [sign in](#sign-in). ChatGPT shows a plugin you add with its own icon, and lists you as the plugin's developer, because you added it. ## Sign in When you connect, your browser opens the Pyas dashboard: 1. Sign in, or create an account. 2. Choose the app that ChatGPT works with. 3. Check what ChatGPT asks for, and select **Allow**. You're then back in ChatGPT. See [Sign in](/ai-tools#sign-in) for what each permission allows. ## Use it in a chat In a new chat, type `@`, choose **Pyas sandbox**, and ask in plain words: > @Pyas sandbox What meetings does Sam Patel have this week? > Find a 30 minute slot for Avery and Riley tomorrow afternoon and book it. > Add a Zoom link to that meeting. The sandbox's sample people, such as Avery Brooks and Sam Patel, have calendars with a week of events, so these work as soon as you connect. ChatGPT asks you before it uses a tool that changes something, such as booking a meeting. Every request ChatGPT makes is on the **Logs** page in the dashboard, marked with the tool that made it. ## Live calendars To work on the real calendars that people connected to your app, add a second plugin: 1. Add `https://mcp.pyas.io` the same way, with the name `Pyas live`. 2. When you sign in, choose whether ChatGPT may only read live calendars, or change them too. Before ChatGPT changes a live calendar, you confirm the change in Pyas, from a link that ChatGPT gives you. See [Live calendars](/ai-tools#live-calendars) for how that works and who can confirm. ## Disconnect On your profile in the Pyas dashboard, **Connected tools** lists ChatGPT with when it was last used. Select **Disconnect** to stop it immediately. To use it again, connect it again from ChatGPT. # Claude ::lead Add Pyas to Claude, and you can ask about your app's calendars in a chat: who is free tomorrow, what is on someone's week, or book a meeting with a video link. You add Pyas by its address and sign in. There is nothing to install and no key to copy. :: Start with the sandbox address, `https://mcp.pyas.io/sandbox`. It works in your app's [sandbox](/sandbox), on sample people, calendars and events, so nothing you try reaches a real calendar. To use Pyas while you code in a terminal, see [Claude Code](/claude-code) instead. ## Add Pyas to Claude 1. In Claude, open **Customize**, then **Connectors**. 2. Select **+ Add**, then **Add custom connector**. 3. Enter the name `Pyas sandbox` and the address `https://mcp.pyas.io/sandbox`. 4. Claude finds how Pyas signs in by itself. Leave **Sign in now** and **Use Claude's published identity** selected, leave **Request headers** empty, and select **Add**. 5. Select **Connect**, then [sign in](#sign-in). Once it's connected, Pyas works in Claude on the web and in Claude's apps. On a Team or Enterprise plan, an owner adds the connector under **Organization settings**, then **Connectors**, and each member then connects it. On the Free plan, you can add one custom connector. ## Sign in When you connect, your browser opens the Pyas dashboard: 1. Sign in, or create an account. 2. Choose the app that Claude works with. 3. Check what Claude asks for, and select **Allow**. You're then back in Claude. See [Sign in](/ai-tools#sign-in) for what each permission allows. ## Choose what needs your approval The connector's page in Claude lists Pyas's tools by name, with the read-only tools in their own group. For each tool, or each group, you choose whether Claude may use it without asking, needs your approval first, or can't use it. We suggest: - **Read-only tools**: let Claude use them without asking. They only read, such as listing events or finding a free time. - **The other tools**: keep **Needs approval**, at least for creating, updating and deleting events. Claude then shows what it's about to do, and you select **Allow once**, **Always allow** or **Decline**. In Claude's Auto mode, Claude can use a tool without asking, even one set to **Needs approval**. On live calendars, Pyas still asks you to confirm each change (see [Live calendars](#live-calendars)). ## Use it in a chat Start a new chat, check that **Pyas sandbox** is on in the chat's tools menu, and ask in plain words: > What accounts do I have in Pyas? > Find a 30 minute slot for Avery and Riley tomorrow afternoon and book it. > Add a Zoom link to that meeting. The sandbox's sample people, such as Avery Brooks and Riley Chen, have calendars with a week of events, so these work as soon as you connect. Claude reads Pyas's tools when a chat starts. When Pyas adds a tool or an option, start a new chat to use it. Every request Claude makes is on the **Logs** page in the dashboard, marked with the tool that made it. ## Live calendars To work on the real calendars that people connected to your app, add a second connector: 1. Add `https://mcp.pyas.io` the same way, with the name `Pyas live`. 2. When you sign in, choose whether Claude may only read live calendars, or change them too. Before Claude changes a live calendar, you confirm the change in Pyas, from a link that Claude gives you. See [Live calendars](/ai-tools#live-calendars) for how that works and who can confirm. While you work in the sandbox, turn **Pyas live** off in the chat's tools menu, so Claude can't mix up the two. ## Disconnect To disconnect in Claude, open the connector under **Customize**, then **Connectors**, and select **Disconnect**. On your profile in the Pyas dashboard, **Connected tools** lists Claude with when it was last used. **Disconnect** there stops it immediately, from Pyas's side. To use it again, connect it again from Claude. # Claude API ::lead This guide adds a calendar assistant to your server with Anthropic's Messages API. The assistant uses Pyas's tools for the signed-in user only, and each change waits for them to approve it. :: The code was tested with `claude-sonnet-5-5`. See [AI in your app](/ai-in-your-app) for how the tools work. To use Pyas yourself in the Claude app, see [Claude](/claude) instead. ## Install ```bash npm install @pyas/ai-tools @anthropic-ai/sdk ``` Set these on your server: ```bash PYAS_API_KEY=pyas_sk_test_... # a test key from API keys in the Pyas dashboard ANTHROPIC_API_KEY=sk-ant-... ``` ## The assistant The assistant has two functions. `ask` sends the person's message. `answer` sends which of the waiting changes they approved. Each returns what to show the person: a reply, or the changes to approve. ```ts [assistant.ts] import Anthropic from '@anthropic-ai/sdk' import type { MessageParam, ToolResultBlockParam } from '@anthropic-ai/sdk/resources/messages' import { pyasAnthropicTools, runPyasToolUses, type PyasPendingToolUse } from '@pyas/ai-tools/anthropic' const anthropic = new Anthropic() /** One person's conversation with the assistant. Save it between requests, for example in your database. */ export interface Conversation { system: string messages: MessageParam[] /** Changes waiting for the person, with the results that go back to Claude alongside them. */ waiting?: { results: ToolResultBlockParam[]; pending: PyasPendingToolUse[] } } /** What to show the person: the assistant's reply, or the changes waiting for their approval. */ export type Turn = | { reply: string } | { changes: Array> } export function newConversation(): Conversation { // The same for the whole conversation: Claude rejects a system prompt that changes partway. const today = new Date().toISOString().slice(0, 10) return { system: `You help the user manage their calendar. Today is ${today}.`, messages: [] } } /** The person sends a message. */ export async function ask(conversation: Conversation, userId: string, text: string): Promise { conversation.messages.push({ role: 'user', content: text }) return run(conversation, userId) } /** The person answers the changes: the ids they approved. Every other change is declined. */ export async function answer(conversation: Conversation, userId: string, approved: string[]): Promise { if (!conversation.waiting) throw new Error('No change is waiting for an answer.') const { results, pending } = conversation.waiting const declined = pending.map((change) => change.id).filter((id) => !approved.includes(id)) const answered = await runPyasToolUses(pending.map((change) => change.call), { subjectId: userId, approved, declined }) // Every tool_result of the turn goes back in one message. conversation.messages.push({ role: 'user', content: [...results, ...answered.results] }) conversation.waiting = undefined return run(conversation, userId) } async function run(conversation: Conversation, userId: string): Promise { const options = { subjectId: userId } for (let step = 0; step < 10; step++) { const message = await anthropic.messages.create({ model: 'claude-sonnet-5-5', max_tokens: 4096, system: conversation.system, messages: conversation.messages, tools: pyasAnthropicTools(options), }) conversation.messages.push({ role: 'assistant', content: message.content }) if (message.stop_reason !== 'tool_use') { return { reply: message.content.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n') } } // Reads run now. Changes come back in pending, and nothing is sent to Pyas until the person approves. const { results, pending } = await runPyasToolUses(message.content, options) if (pending.length) { conversation.waiting = { results, pending } return { changes: pending.map(({ id, title, input, event }) => ({ id, title, input, event })) } } conversation.messages.push({ role: 'user', content: results }) } return { reply: 'I stopped before finishing. Please ask again.' } } ``` The tools that only read run as soon as Claude calls them. A change comes back in `pending`, and nothing is sent to Pyas until the person approves it. Claude needs every `tool_result` of a turn in one message, so the results of the other calls wait with the change and go back together. Keep the system prompt the same for the whole conversation. Claude rejects a conversation whose system prompt changes partway, so put today's date in it, not the time. ## Your routes Your server keeps each conversation between requests and passes the signed-in person's ID. With Express: ```ts [server.ts] import express from 'express' import { answer, ask, newConversation, type Conversation } from './assistant.js' /** The signed-in person's ID in your app, from your sign-in (here, the sandbox's Avery Brooks). */ function currentUserId(_req: express.Request): string | null { return 'sandbox_user_1' } // One conversation per person. In production, keep them in your database. const conversations = new Map() const app = express() app.use(express.json()) app.post('/chat', async (req, res) => { const userId = currentUserId(req) if (!userId) return res.status(401).json({ error: 'Sign in to use your calendar.' }) const conversation = conversations.get(userId) ?? newConversation() conversations.set(userId, conversation) res.json(await ask(conversation, userId, req.body.text)) }) // The person's answer: { "approved": ["call_..."] }. Changes left out are declined. app.post('/chat/answer', async (req, res) => { const userId = currentUserId(req) if (!userId) return res.status(401).json({ error: 'Sign in to use your calendar.' }) const conversation = conversations.get(userId) if (!conversation?.waiting) return res.status(409).json({ error: 'No change is waiting for an answer.' }) res.json(await answer(conversation, userId, req.body.approved ?? [])) }) app.listen(3457) ``` `currentUserId` stands for your sign-in. It returns the ID you saved as `subjectId` when the person connected their calendar. To try the guide in the sandbox, have it return `'sandbox_user_1'`, the sandbox person Avery Brooks. ## Show the change When Claude wants to change something, `ask` returns the changes instead of a reply: ```json { "changes": [ { "id": "toolu_01FXjdyHnNFWqNYuc66PPHc1", "title": "Delete an event", "input": { "accountId": "sbx_f7bb6446c0_1", "eventId": "sbc90a16505a04a760bc4a34a0" }, "event": { "id": "sbc90a16505a04a760bc4a34a0", "title": "Design Review", "startsAt": "2026-11-10T20:00:00Z", "endsAt": "2026-11-10T21:00:00Z", "startsAtLocal": "2026-11-10T15:00:00-05:00", "endsAtLocal": "2026-11-10T16:00:00-05:00", "timezone": "America/New_York", "isAllDay": false, "location": "Room 4B", "attendees": [ { "email": "teammate1.1@example.com", "response": "accepted" }, { "email": "teammate2.1@example.com", "response": "needsAction" } ], "version": "\"1\"" } } ] } ``` Show each change with **Approve** and **Decline**: - `title` says what the change does. - `event` is the event as it is now in Pyas, for a change to an event that already exists. Show its title and time: it's what the change is really about, whatever Claude says. A new event has no `event`, so show the title and times from `input`. Send the IDs the person approved to `/chat/answer`. The others are declined: Claude is told, and they're never sent to Pyas. ::screenshot{src="/images/approval-in-your-app.png" alt="A chat. The person asked to move their 1:1 with Sarah to 4:30 PM. A card shows the event's current time and the new time, with Approve and Decline buttons." width="660" height="399" caption="One way to show a change: the event's current time from Pyas, and the new time Claude asked for."} :: ## Try it 1. Run your server with a test key, and with `currentUserId` returning `'sandbox_user_1'`. 2. Ask "What's on my calendar tomorrow?". The reply lists Avery's events, without asking anything. 3. Ask "Book a 30 minute meeting tomorrow afternoon when I'm free." You get one change, "Create an event". Approve it, and the reply says it's booked. 4. Ask Claude to delete that meeting, and decline. The meeting stays, and the reply says it wasn't deleted. To go live, see [Go live](/ai-in-your-app#go-live). # Claude Code ::lead Add Pyas to Claude Code, and Claude can set up your Pyas app, write the code that connects calendars, and check your sandbox while you code. You add Pyas with one command and sign in. There is nothing to install and no key to copy. :: Start with the sandbox address, `https://mcp.pyas.io/sandbox`. It works in your app's [sandbox](/sandbox), on sample people, calendars and events, so nothing you try reaches a real calendar. ## Add Pyas to Claude Code 1. In a terminal, run: ```bash claude mcp add --transport http --scope user pyas-sandbox https://mcp.pyas.io/sandbox ``` `--scope user` adds Pyas to every project on your computer. Leave it out to add Pyas to the current project only. 2. Start Claude Code, run `/mcp`, select **pyas-sandbox**, then select **Authenticate**. Your browser opens to [sign in](#sign-in). If your browser doesn't open, copy the address that Claude Code shows and open it yourself. In the Claude Code extension for VS Code, type `/mcp` in the chat panel to add the server and sign in from there. ## Sign in When you authenticate, your browser opens the Pyas dashboard: 1. Sign in, or create an account. 2. Choose the app that Claude Code works with. 3. Check what Claude Code asks for, and select **Allow**. Go back to the terminal. `/mcp` now shows **pyas-sandbox** as connected, with its tools. See [Sign in](/ai-tools#sign-in) for what each permission allows. ## Use it while you code Ask in plain words, in the project you're working on: > Set up Pyas in this app so people can connect their Google or Outlook calendar. Claude checks your app's setup with `setup_status`, gets the code for your stack with `get_recipe`, and creates a test key with `get_test_key`. It tells you where to store the key, such as `PYAS_API_KEY` in your `.env` file. Recipes cover Next.js and Express, and the AI app builders. > What's on Avery's calendar tomorrow? > My last request to Pyas failed. Why? The sandbox's sample people, such as Avery Brooks and Riley Chen, have calendars with a week of events. For a failed request, Claude looks it up with `get_request` and `explain_error`, from its trace ID. Every request Claude Code makes is on the **Logs** page in the dashboard, marked with the tool that made it. ## Choose what needs your approval The first time Claude uses a Pyas tool, Claude Code asks you. Select **Yes** to allow it once, or **Yes, and don't ask again** to allow that tool from then on. To allow every Pyas tool in the sandbox without being asked, add a rule to `.claude/settings.json` in your project, or to `~/.claude/settings.json`: ```json { "permissions": { "allow": ["mcp__pyas-sandbox__*"] } } ``` Don't add a rule like this for the live server. Keep being asked before Claude changes a live calendar. ## Live calendars To work on the real calendars that people connected to your app, add a second server: 1. Run: ```bash claude mcp add --transport http --scope user pyas-live https://mcp.pyas.io ``` 2. In `/mcp`, select **pyas-live**, then **Authenticate**. When you sign in, choose whether Claude may only read live calendars, or change them too. Before Claude changes a live calendar, you confirm the change in Pyas, from a link that Claude gives you. See [Live calendars](/ai-tools#live-calendars) for how that works and who can confirm. While you work in the sandbox, turn the live server off: run `/mcp`, select **pyas-live**, then **Disable**. ## Disconnect To sign Claude Code out of Pyas, run: ```bash claude mcp logout pyas-sandbox claude mcp remove pyas-sandbox ``` `logout` signs out, and `remove` deletes the server from Claude Code. To stop Claude Code from Pyas's side, open your profile in the Pyas dashboard. **Connected tools** lists Claude Code with when it was last used, and **Disconnect** stops it immediately. To use it again, authenticate again in `/mcp`. # Codex ::lead Add Pyas to Codex, and it can set up your Pyas app, write the code that connects calendars, and check your sandbox while you code. You add Pyas by its address and sign in. There is nothing to install and no key to copy. :: Start with the sandbox address, `https://mcp.pyas.io/sandbox`. It works in your app's [sandbox](/sandbox), on sample people, calendars and events, so nothing you try reaches a real calendar. The Codex CLI, the Codex IDE extension and Codex in the ChatGPT desktop app share their settings, so you add Pyas once for all three. ## Add Pyas to Codex In a terminal, run: ```bash codex mcp add pyas-sandbox --url https://mcp.pyas.io/sandbox codex mcp login pyas-sandbox ``` `login` opens your browser to [sign in](#sign-in). Codex doesn't ask you to sign in by itself, so run it after you add the server. Or add it without the terminal: - **In the IDE extension:** open the gear menu, then **MCP servers**, and select **Add server**. Choose **Streamable HTTP**, enter the address, and restart the extension. Then select **Authenticate** next to the server. - **In the ChatGPT desktop app:** open **Settings**, then **MCP servers**, and select **Add server**. Enter the address, restart the app, then select **Authenticate** next to the server. - **In `~/.codex/config.toml`:** ```toml [mcp_servers.pyas-sandbox] url = "https://mcp.pyas.io/sandbox" ``` ## Sign in When you sign in, your browser opens the Pyas dashboard: 1. Sign in, or create an account. 2. Choose the app that Codex works with. 3. Check what Codex asks for, and select **Allow**. Go back to Codex. In the CLI, `/mcp` lists Pyas's tools. See [Sign in](/ai-tools#sign-in) for what each permission allows. ## Use it while you code Ask in plain words, in the project you're working on: > Set up Pyas in this app so people can connect their Google or Outlook calendar. Codex checks your app's setup with `setup_status`, gets the code for your stack with `get_recipe`, and creates a test key with `get_test_key`. It tells you where to store the key, such as `PYAS_API_KEY` in your `.env` file. Recipes cover Next.js and Express, and the AI app builders. > What's on Avery's calendar tomorrow? > My last request to Pyas failed. Why? The sandbox's sample people, such as Avery Brooks and Riley Chen, have calendars with a week of events. For a failed request, Codex looks it up with `get_request` and `explain_error`, from its trace ID. Every request Codex makes is on the **Logs** page in the dashboard, marked with the tool that made it. ## Choose what needs your approval Each Pyas tool says whether it only reads. To let Codex use the tools that read without asking, and ask you before every change, set `writes` in `~/.codex/config.toml`: ```toml [mcp_servers.pyas-sandbox] url = "https://mcp.pyas.io/sandbox" default_tools_approval_mode = "writes" ``` Use the same setting for the live server. ## Live calendars To work on the real calendars that people connected to your app, add a second server: ```bash codex mcp add pyas-live --url https://mcp.pyas.io codex mcp login pyas-live ``` When you sign in, choose whether Codex may only read live calendars, or change them too. Before Codex changes a live calendar, you confirm the change in Pyas, from a link that Codex gives you. See [Live calendars](/ai-tools#live-calendars) for how that works and who can confirm. While you work in the sandbox, turn the live server off. Add `enabled = false` under `[mcp_servers.pyas-live]` in `~/.codex/config.toml`, and remove it when you need live calendars. ## Disconnect To sign Codex out of Pyas, run: ```bash codex mcp logout pyas-sandbox codex mcp remove pyas-sandbox ``` `logout` signs out, and `remove` deletes the server from Codex. To stop Codex from Pyas's side, open your profile in the Pyas dashboard. **Connected tools** lists Codex with when it was last used, and **Disconnect** stops it immediately. To use it again, run `codex mcp login` again. # Cursor ::lead Add Pyas to Cursor, and its agent can set up your Pyas app, write the code that connects calendars, and check your sandbox while you code. You add Pyas by its address and sign in. There is nothing to install and no key to copy. :: Start with the sandbox address, `https://mcp.pyas.io/sandbox`. It works in your app's [sandbox](/sandbox), on sample people, calendars and events, so nothing you try reaches a real calendar. ## Add Pyas to Cursor Select [Add to Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=pyas-sandbox&config=eyJ1cmwiOiJodHRwczovL21jcC5weWFzLmlvL3NhbmRib3gifQ%3D%3D). Cursor opens and asks you to install **pyas-sandbox**. Or add it yourself: 1. Add the server to `~/.cursor/mcp.json` to use it in every project, or to `.cursor/mcp.json` in one project: ```json { "mcpServers": { "pyas-sandbox": { "url": "https://mcp.pyas.io/sandbox" } } } ``` 2. Save the file and restart Cursor. To see the server, open **Customize** in the sidebar, then **MCPs**. ## Sign in The first time Cursor connects to Pyas, your browser opens the Pyas dashboard: 1. Sign in, or create an account. 2. Choose the app that Cursor works with. 3. Check what Cursor asks for, and select **Allow**. Go back to Cursor. Under **Customize**, then **MCPs**, **pyas-sandbox** lists its tools. See [Sign in](/ai-tools#sign-in) for what each permission allows. ## Use it while you code Ask the agent in plain words, in the project you're working on: > Set up Pyas in this app so people can connect their Google or Outlook calendar. The agent checks your app's setup with `setup_status`, gets the code for your stack with `get_recipe`, and creates a test key with `get_test_key`. It tells you where to store the key, such as `PYAS_API_KEY` in your `.env` file. Recipes cover Next.js and Express, and the AI app builders. > What's on Avery's calendar tomorrow? > My last request to Pyas failed. Why? The sandbox's sample people, such as Avery Brooks and Riley Chen, have calendars with a week of events. For a failed request, the agent looks it up with `get_request` and `explain_error`, from its trace ID. To leave out a tool, select its name in the tools list at the top of the chat panel. Every request Cursor makes is on the **Logs** page in the dashboard, marked with the tool that made it. ## Choose what needs your approval Cursor asks you before it uses a Pyas tool. To let it use every Pyas tool in the sandbox without asking, add the server to the allowlist in `~/.cursor/permissions.json`: ```json { "mcpAllowlist": ["pyas-sandbox:*"] } ``` Don't add the live server to the allowlist. Keep being asked before the agent changes a live calendar. ## Live calendars To work on the real calendars that people connected to your app, add a second server next to the sandbox: ```json { "mcpServers": { "pyas-sandbox": { "url": "https://mcp.pyas.io/sandbox" }, "pyas-live": { "url": "https://mcp.pyas.io" } } } ``` When you sign in, choose whether Cursor may only read live calendars, or change them too. Before the agent changes a live calendar, you confirm the change in Pyas, from a link that it gives you. See [Live calendars](/ai-tools#live-calendars) for how that works and who can confirm. While you work in the sandbox, turn the live server off with the switch next to **pyas-live**, under **Customize**, then **MCPs**. ## Disconnect To remove Pyas from Cursor, remove it under **Customize**, then **MCPs**, or delete its entry from `mcp.json`. To stop Cursor from Pyas's side, open your profile in the Pyas dashboard. **Connected tools** lists Cursor with when it was last used, and **Disconnect** stops it immediately. To use it again, add it again and sign in. # LangChain ::lead This guide adds a calendar assistant to your server with a LangChain agent. The agent uses Pyas's tools for the signed-in user only. When it wants to change a calendar, it pauses until the person approves. :: The code was tested with LangChain 1.6, LangGraph 1.7, and OpenAI's `gpt-5.5` and Anthropic's `claude-sonnet-5-5` as the model. See [AI in your app](/ai-in-your-app) for how the tools work. ## Install ```bash npm install @pyas/ai-tools langchain @langchain/core @langchain/langgraph @langchain/openai ``` Set these on your server: ```bash PYAS_API_KEY=pyas_sk_test_... # a test key from API keys in the Pyas dashboard OPENAI_API_KEY=sk-... ``` For Claude, install `@langchain/anthropic`, set `ANTHROPIC_API_KEY`, and use `anthropic:claude-sonnet-5-5` as the model. ## The agent ```ts [assistant.ts] import { createAgent } from 'langchain' import { Command, MemorySaver } from '@langchain/langgraph' import { pyasToolkit, type PyasApprovalRequest } from '@pyas/ai-tools/langchain' // Keeps each conversation between requests. In production, use a database checkpointer, // such as PostgresSaver from @langchain/langgraph-checkpoint-postgres. const checkpointer = new MemorySaver() /** What to show the person: the assistant's reply, or the changes waiting for their approval. */ export type Turn = | { reply: string } | { changes: Array<{ id: string } & PyasApprovalRequest> } function agentFor(userId: string) { // The same for the whole conversation. Today's date lets the model work out "tomorrow". const today = new Date().toISOString().slice(0, 10) return createAgent({ model: 'openai:gpt-5.5', tools: pyasToolkit({ subjectId: userId }), systemPrompt: `You help the user manage their calendar. Today is ${today}.`, checkpointer, }) } function turn(result: { messages: Array<{ text: string }>; __interrupt__?: Array<{ id?: string; value?: unknown }> }): Turn { const waiting = result.__interrupt__ ?? [] if (waiting.length) return { changes: waiting.map((change) => ({ id: change.id!, ...(change.value as PyasApprovalRequest) })) } return { reply: result.messages.at(-1)?.text ?? '' } } /** The person sends a message. conversationId is yours: one per conversation. */ export async function ask(conversationId: string, userId: string, text: string): Promise { const config = { configurable: { thread_id: conversationId } } return turn(await agentFor(userId).invoke({ messages: [{ role: 'user', content: text }] }, config)) } /** The person answers the changes: the ids they approved. Every other change is declined. */ export async function answer(conversationId: string, userId: string, changeIds: string[], approved: string[]): Promise { const config = { configurable: { thread_id: conversationId } } const resume = Object.fromEntries(changeIds.map((id) => [id, { approved: approved.includes(id) }])) return turn(await agentFor(userId).invoke(new Command({ resume }), config)) } ``` A tool that changes a calendar pauses the agent with LangGraph's `interrupt()`. The agent needs a checkpointer to pause: it saves the conversation, and the agent carries on from the same point once the person approves or declines. Outside an agent, such a tool fails rather than run without approval. `answer` resumes the agent with `{ approved: true }` or `{ approved: false }` for each change, by the change's ID. A declined change is never sent to Pyas, and the model is told. Keep the system prompt the same for the whole conversation. Some models, such as Claude, reject a conversation whose system prompt changes partway, so put today's date in it, not the time. ## Your routes Your server keeps each person's conversation ID and the changes waiting for them. With Express: ```ts [server.ts] import express from 'express' import { randomUUID } from 'node:crypto' import { answer, ask } from './assistant.js' /** The signed-in person's ID in your app, from your sign-in (here, the sandbox's Avery Brooks). */ function currentUserId(_req: express.Request): string | null { return 'sandbox_user_1' } // Each person's conversation and the changes waiting for them. In production, keep these in your database. const conversations = new Map() const app = express() app.use(express.json()) app.post('/chat', async (req, res) => { const userId = currentUserId(req) if (!userId) return res.status(401).json({ error: 'Sign in to use your calendar.' }) const conversation = conversations.get(userId) ?? { id: randomUUID(), waiting: [] } conversations.set(userId, conversation) const turn = await ask(conversation.id, userId, req.body.text) conversation.waiting = 'changes' in turn ? turn.changes.map((change) => change.id) : [] res.json(turn) }) // The person's answer: { "approved": ["..."] }. Changes left out are declined. app.post('/chat/answer', async (req, res) => { const userId = currentUserId(req) if (!userId) return res.status(401).json({ error: 'Sign in to use your calendar.' }) const conversation = conversations.get(userId) if (!conversation?.waiting.length) return res.status(409).json({ error: 'No change is waiting for an answer.' }) const turn = await answer(conversation.id, userId, conversation.waiting, req.body.approved ?? []) conversation.waiting = 'changes' in turn ? turn.changes.map((change) => change.id) : [] res.json(turn) }) app.listen(3457) ``` `currentUserId` stands for your sign-in. It returns the ID you saved as `subjectId` when the person connected their calendar. To try the guide in the sandbox, have it return `'sandbox_user_1'`, the sandbox person Avery Brooks. ## Show the change When the agent wants to change something, `ask` returns the changes instead of a reply: ```json { "changes": [ { "id": "c9d154673bb91cf774e8a964e2891f8c", "type": "pyas_approval", "tool": "delete_event", "title": "Delete an event", "input": { "accountId": "sbx_f7bb6446c0_1", "eventId": "sbc90a16505a04a760bc4a34a0" }, "event": { "id": "sbc90a16505a04a760bc4a34a0", "title": "Design Review", "startsAt": "2026-11-10T20:00:00Z", "endsAt": "2026-11-10T21:00:00Z", "startsAtLocal": "2026-11-10T15:00:00-05:00", "endsAtLocal": "2026-11-10T16:00:00-05:00", "timezone": "America/New_York", "isAllDay": false, "location": "Room 4B", "version": "\"1\"" } } ] } ``` Show each change with **Approve** and **Decline**: - `title` says what the change does. - `event` is the event as it is now in Pyas, for a change to an event that already exists. Show its title and time: it's what the change is really about, whatever the model says. A new event has no `event`, so show the title and times from `input`. Send the IDs the person approved to `/chat/answer`. The others are declined. ::screenshot{src="/images/approval-in-your-app.png" alt="A chat. The person asked to move their 1:1 with Sarah to 4:30 PM. A card shows the event's current time and the new time, with Approve and Decline buttons." width="660" height="399" caption="One way to show a change: the event's current time from Pyas, and the new time the model asked for."} :: ## Try it 1. Run your server with a test key, and with `currentUserId` returning `'sandbox_user_1'`. 2. Ask "What's on my calendar tomorrow?". The reply lists Avery's events, without pausing. 3. Ask "Book a 30 minute meeting tomorrow afternoon when I'm free." The agent pauses with one change, "Create an event". Approve it, and the reply says it's booked. 4. Ask it to delete that meeting, and decline. The meeting stays, and the reply says it was left as it was. To go live, see [Go live](/ai-in-your-app#go-live). # Build with Lovable ::lead Lovable's agent can add Google Calendar and Microsoft Outlook to your app with Pyas. You connect the Pyas MCP server to Lovable once. The agent then gets the code for each task from Pyas, and you store a key as a secret. The key stays on the server, and the code works with the sandbox's sample people before anyone connects a real calendar. :: ## Add Pyas to Lovable 1. In Lovable, open **Connectors**, select **Add custom** at the top right, then select **MCP server**. 2. For **Server name**, enter `Pyas`. For **Server URL**, enter `https://mcp.pyas.io/sandbox`. Leave the authentication on **OAuth**. 3. Select **Add & authorize**. Sign in to Pyas, choose your app, and select **Allow**. See [Sign in](/ai-tools#sign-in) for what each permission allows. The connection is yours. It gives Lovable's agent the Pyas tools while you build, and it is never part of your published app. Custom MCP servers are available on every Lovable plan. If **MCP server** can't be selected, an admin of your workspace has turned custom servers off. The agent uses the Pyas tools that only read without asking. Before a tool that changes something, such as creating a test key, it shows a card for you to approve. To stop or remove the connection, open **Connectors**. ## Ask for what you want Ask in plain words, for example: > Add a button that lets people connect their Google or Outlook calendar, and show their events for the next week. The agent checks your setup with `setup_status` and gets the code for each task with `get_recipe`. It adds your app's preview address to the allowed origins with `update_app`, and creates a test key with `get_test_key`. You confirm the address in Pyas, from the link it gives you. Every response from Pyas ends with the next step, so the agent knows what to do next. ## Store the key Store the test key as a secret named `PYAS_API_KEY`: 1. In Lovable, open **More**, then **Settings**, then **Secrets**. 2. Use the **Add secret** form. Don't paste the key into the chat. Your app's server code reads the secret, and it never reaches the browser. A test key works only in your app's [sandbox](/sandbox). ## What Lovable adds Lovable projects created since May 13, 2026 run on TanStack Start, and the code runs as server functions inside your app: ```text src/lib/pyas.server.ts the Pyas client, on the server only src/lib/pyas-connect.functions.ts creates a connect session for the button src/lib/pyas-events.functions.ts the person's events for the next 7 days src/lib/pyas-book.functions.ts books the first free 30 minutes tomorrow src/components/ConnectCalendar.tsx the "Connect an account" button ``` Your pages call the functions directly, for example `await listEvents()`. The function that lists events looks like this: ```ts [src/lib/pyas-events.functions.ts] import { createServerFn } from '@tanstack/react-start' const DAY = 24 * 60 * 60 * 1000 export const listEvents = createServerFn({ method: 'GET' }).handler(async ({ context }) => { // Loaded here, on the server: this file is also part of the browser's code. const { errorResult, pyas, signInRequired, subjectFor } = await import('./pyas.server') const subjectId = subjectFor(context) if (!subjectId) return signInRequired() try { const now = Date.now() const page = await pyas().events.list({ subjectId, from: new Date(now).toISOString(), to: new Date(now + 7 * DAY).toISOString(), expand: true, sort: 'start', limit: 50, }) // Titles are written by whoever sent the invitation: show them as text, never as HTML. return { events: page.data.map((event) => ({ id: event.id, title: event.title, startsAt: event.startsAt, endsAt: event.endsAt, isAllDay: event.isAllDay, provider: event.provider })) } } catch (error) { return errorResult(error) } }) ``` With a test key, the functions use the sandbox's sample person, so the events and booking work right away. The connect button shows a message from Pyas that connecting a real calendar requires a live key. This is expected. In an older Lovable project, built with React and Vite, the agent adds Supabase Edge Functions in `supabase/functions/` instead, and your pages call them with `supabase.functions.invoke('pyas-events')`. ## Receive webhooks Ask the agent to receive Pyas webhooks. It adds a public route, `src/routes/api/public/pyas-webhooks.ts`, which verifies the signature of each delivery against the raw body before it reads the event. The agent adds the endpoint to your sandbox with `create_webhook`, at your published address: ```text https://.lovable.app/api/public/pyas-webhooks ``` Store the signing secret that `create_webhook` returns as a secret named `PYAS_WEBHOOK_SECRET`. See [Webhooks](/webhooks) for the events Pyas sends. ## Go live 1. Your app needs sign-in before people connect a calendar. Ask the agent to add sign-in, and to add it to the Pyas server functions. The functions then use the signed-in person. Until then, they respond with the code `SIGN_IN_REQUIRED` to every request made with a live key, so people's calendars never mix. 2. In the Pyas dashboard, choose a plan and create a live key. Plans, payment and live keys are always done by you, in the dashboard. 3. Replace the `PYAS_API_KEY` secret with the live key. 4. Add your published address, such as `https://your-app.lovable.app`, to the app's allowed origins, and add a live webhook endpoint in the dashboard. # OpenAI ::lead This guide adds a calendar assistant to your server with OpenAI's API, with Chat Completions or the Responses API. The assistant uses Pyas's tools for the signed-in user only, and each change waits for them to approve it. :: The code was tested with `gpt-5.5`. Any model that calls functions works. See [AI in your app](/ai-in-your-app) for how the tools work. ## Install ```bash npm install @pyas/ai-tools openai ``` Set these on your server: ```bash PYAS_API_KEY=pyas_sk_test_... # a test key from API keys in the Pyas dashboard OPENAI_API_KEY=sk-... ``` ## The assistant The assistant has two functions. `ask` sends the person's message. `answer` sends which of the waiting changes they approved. Each returns what to show the person: a reply, or the changes to approve. ::code-group ```ts [Chat Completions] import OpenAI from 'openai' import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions' import { pyasFunctionDefinitions, runPyasToolCalls, type PyasPendingToolCall } from '@pyas/ai-tools/openai' const openai = new OpenAI() /** One person's conversation with the assistant. Save it between requests, for example in your database. */ export interface Conversation { messages: ChatCompletionMessageParam[] /** Changes waiting for the person, with the results that go back to the model alongside them. */ waiting?: { results: ChatCompletionMessageParam[]; pending: PyasPendingToolCall[] } } /** What to show the person: the assistant's reply, or the changes waiting for their approval. */ export type Turn = | { reply: string } | { changes: Array> } export function newConversation(): Conversation { // The same for the whole conversation. Today's date lets the model work out "tomorrow". const today = new Date().toISOString().slice(0, 10) return { messages: [{ role: 'system', content: `You help the user manage their calendar. Today is ${today}.` }] } } /** The person sends a message. */ export async function ask(conversation: Conversation, userId: string, text: string): Promise { conversation.messages.push({ role: 'user', content: text }) return run(conversation, userId) } /** The person answers the changes: the ids they approved. Every other change is declined. */ export async function answer(conversation: Conversation, userId: string, approved: string[]): Promise { if (!conversation.waiting) throw new Error('No change is waiting for an answer.') const { results, pending } = conversation.waiting const declined = pending.map((change) => change.id).filter((id) => !approved.includes(id)) const answered = await runPyasToolCalls(pending.map((change) => change.call), { subjectId: userId, approved, declined }) conversation.messages.push(...results, ...answered.results) conversation.waiting = undefined return run(conversation, userId) } async function run(conversation: Conversation, userId: string): Promise { const options = { subjectId: userId } for (let step = 0; step < 10; step++) { const response = await openai.chat.completions.create({ model: 'gpt-5.5', messages: conversation.messages, tools: pyasFunctionDefinitions(options), }) const message = response.choices[0].message conversation.messages.push(message) if (!message.tool_calls?.length) return { reply: message.content ?? '' } // Reads run now. Changes come back in pending, and nothing is sent to Pyas until the person approves. const { results, pending } = await runPyasToolCalls(message.tool_calls, options) if (pending.length) { conversation.waiting = { results, pending } return { changes: pending.map(({ id, title, input, event }) => ({ id, title, input, event })) } } conversation.messages.push(...results) } return { reply: 'I stopped before finishing. Please ask again.' } } ``` ```ts [Responses API] import OpenAI from 'openai' import type { ResponseInputItem } from 'openai/resources/responses/responses' import { pyasFunctionDefinitions, runPyasFunctionCalls, type PyasPendingFunctionCall } from '@pyas/ai-tools/openai' const openai = new OpenAI() /** One person's conversation with the assistant. Save it between requests, for example in your database. */ export interface Conversation { instructions: string input: ResponseInputItem[] /** Changes waiting for the person, with the results that go back to the model alongside them. */ waiting?: { results: ResponseInputItem[]; pending: PyasPendingFunctionCall[] } } /** What to show the person: the assistant's reply, or the changes waiting for their approval. */ export type Turn = | { reply: string } | { changes: Array> } export function newConversation(): Conversation { // The same for the whole conversation. Today's date lets the model work out "tomorrow". const today = new Date().toISOString().slice(0, 10) return { instructions: `You help the user manage their calendar. Today is ${today}.`, input: [] } } /** The person sends a message. */ export async function ask(conversation: Conversation, userId: string, text: string): Promise { conversation.input.push({ role: 'user', content: text }) return run(conversation, userId) } /** The person answers the changes: the ids they approved. Every other change is declined. */ export async function answer(conversation: Conversation, userId: string, approved: string[]): Promise { if (!conversation.waiting) throw new Error('No change is waiting for an answer.') const { results, pending } = conversation.waiting const declined = pending.map((change) => change.id).filter((id) => !approved.includes(id)) const answered = await runPyasFunctionCalls(pending.map((change) => change.call), { subjectId: userId, approved, declined }) conversation.input.push(...results, ...answered.results) conversation.waiting = undefined return run(conversation, userId) } async function run(conversation: Conversation, userId: string): Promise { const options = { subjectId: userId } for (let step = 0; step < 10; step++) { const response = await openai.responses.create({ model: 'gpt-5.5', instructions: conversation.instructions, input: conversation.input, tools: pyasFunctionDefinitions({ ...options, api: 'responses' }), }) conversation.input.push(...(response.output as ResponseInputItem[])) const calls = response.output.filter((item) => item.type === 'function_call') if (!calls.length) return { reply: response.output_text } // Reads run now. Changes come back in pending, and nothing is sent to Pyas until the person approves. const { results, pending } = await runPyasFunctionCalls(calls, options) if (pending.length) { conversation.waiting = { results, pending } return { changes: pending.map(({ id, title, input, event }) => ({ id, title, input, event })) } } conversation.input.push(...results) } return { reply: 'I stopped before finishing. Please ask again.' } } ``` :: The tools that only read run as soon as the model calls them. A change comes back in `pending`, and nothing is sent to Pyas until the person approves it. The model's other calls in the same turn wait with it, so everything goes back to the model together. ## Your routes Your server keeps each conversation between requests and passes the signed-in person's ID. With Express: ```ts [server.ts] import express from 'express' import { answer, ask, newConversation, type Conversation } from './assistant.js' /** The signed-in person's ID in your app, from your sign-in (here, the sandbox's Avery Brooks). */ function currentUserId(_req: express.Request): string | null { return 'sandbox_user_1' } // One conversation per person. In production, keep them in your database. const conversations = new Map() const app = express() app.use(express.json()) app.post('/chat', async (req, res) => { const userId = currentUserId(req) if (!userId) return res.status(401).json({ error: 'Sign in to use your calendar.' }) const conversation = conversations.get(userId) ?? newConversation() conversations.set(userId, conversation) res.json(await ask(conversation, userId, req.body.text)) }) // The person's answer: { "approved": ["call_..."] }. Changes left out are declined. app.post('/chat/answer', async (req, res) => { const userId = currentUserId(req) if (!userId) return res.status(401).json({ error: 'Sign in to use your calendar.' }) const conversation = conversations.get(userId) if (!conversation?.waiting) return res.status(409).json({ error: 'No change is waiting for an answer.' }) res.json(await answer(conversation, userId, req.body.approved ?? [])) }) app.listen(3457) ``` `currentUserId` stands for your sign-in. It returns the ID you saved as `subjectId` when the person connected their calendar. To try the guide in the sandbox, have it return `'sandbox_user_1'`, the sandbox person Avery Brooks. ## Show the change When the model wants to change something, `ask` returns the changes instead of a reply: ```json { "changes": [ { "id": "call_E8cjRdIVEYHkd79LZw8UG4de", "title": "Delete an event", "input": { "accountId": "sbx_f7bb6446c0_1", "eventId": "sbc90a16505a04a760bc4a34a0" }, "event": { "id": "sbc90a16505a04a760bc4a34a0", "title": "Design Review", "startsAt": "2026-11-10T20:00:00Z", "endsAt": "2026-11-10T21:00:00Z", "startsAtLocal": "2026-11-10T15:00:00-05:00", "endsAtLocal": "2026-11-10T16:00:00-05:00", "timezone": "America/New_York", "isAllDay": false, "location": "Room 4B", "attendees": [ { "email": "teammate1.1@example.com", "response": "accepted" }, { "email": "teammate2.1@example.com", "response": "needsAction" } ], "version": "\"1\"" } } ] } ``` Show each change with **Approve** and **Decline**: - `title` says what the change does. - `event` is the event as it is now in Pyas, for a change to an event that already exists. Show its title and time: it's what the change is really about, whatever the model says. A new event has no `event`, so show the title and times from `input`. Send the IDs the person approved to `/chat/answer`. The others are declined: the model is told, and they're never sent to Pyas. ::screenshot{src="/images/approval-in-your-app.png" alt="A chat. The person asked to move their 1:1 with Sarah to 4:30 PM. A card shows the event's current time and the new time, with Approve and Decline buttons." width="660" height="399" caption="One way to show a change: the event's current time from Pyas, and the new time the model asked for."} :: ## Try it 1. Run your server with a test key, and with `currentUserId` returning `'sandbox_user_1'`. 2. Ask "What's on my calendar tomorrow?". The reply lists Avery's events, without asking anything. 3. Ask "Book a 30 minute meeting tomorrow afternoon when I'm free." You get one change, "Create an event". Approve it, and the reply says it's booked. 4. Ask it to delete that meeting, and decline. The meeting stays, and the reply says it was left as it was. To go live, see [Go live](/ai-in-your-app#go-live). # Build with Replit ::lead Replit Agent can add Google Calendar and Microsoft Outlook to your app with Pyas. You connect the Pyas MCP server to Replit once. The agent then gets the code for each task from Pyas, and you store a key as a secret. The key stays on the server, and the code works with the sandbox's sample people before anyone connects a real calendar. :: ## Add Pyas to Replit Select [Add Pyas to Replit](https://replit.com/integrations?mcp=eyJkaXNwbGF5TmFtZSI6IlB5YXMiLCJiYXNlVXJsIjoiaHR0cHM6Ly9tY3AucHlhcy5pby9zYW5kYm94In0%3D). Replit opens your integration settings with Pyas filled in. Select **Add to Replit**, then sign in. Or add it yourself: 1. In Replit, open **Settings**, then **Integrations**. 2. Under **Your integrations**, select **Add custom**, then **Add personal MCP server**. 3. For **Display name**, enter `Pyas`. For **MCP Server URL**, enter `https://mcp.pyas.io/sandbox`. 4. Select **Test & save**. Replit registers itself with Pyas and opens the sign-in. Sign in to Pyas, choose your app, and select **Allow**. See [Sign in](/ai-tools#sign-in) for what each permission allows. The Pyas tools are then available to Replit Agent in all of your projects. When a tool needs your confirmation, Replit asks you before it runs. ## Ask for what you want Ask in plain words, for example: > Add a button that lets people connect their Google or Outlook calendar, and show their events for the next week. The agent checks your setup with `setup_status` and gets the code for each task with `get_recipe`. It adds your app's address to the allowed origins with `update_app`, and creates a test key with `get_test_key`. You confirm the address in Pyas, from the link it gives you. Every response from Pyas ends with the next step, so the agent knows what to do next. ## Store the key Store the test key as a secret named `PYAS_API_KEY`: 1. In your project, open **Tools**, then **Secrets**. Select **New Secret**, enter the name and the key, and select **Add Secret**. 2. When you publish, add it again for the published app: open **Publishing**, then **Adjust settings**, then **Production app secrets**. Secrets in the editor don't carry over to the published app. Your server reads the secret as `process.env.PYAS_API_KEY`, and it never reaches the browser. A test key works only in your app's [sandbox](/sandbox). ## What Replit Agent adds The code runs in your app's Express server: ```text server/pyas.ts the Pyas client, and who is signed in server/routes/pyas-connect-session.ts creates a connect session for the button server/routes/pyas-events.ts the person's events for the next 7 days server/routes/pyas-book.ts books the first free 30 minutes tomorrow client/ConnectCalendar.tsx the "Connect an account" button ``` Each route file exports a router, which the agent mounts in your server with `app.use()`. Your pages call the routes with `fetch`, for example `fetch('/api/pyas/events')`. With a test key, the routes use the sandbox's sample person, so the events and booking work right away. The connect button shows a message from Pyas that connecting a real calendar requires a live key. This is expected. ## Receive webhooks Ask the agent to receive Pyas webhooks. It adds `server/routes/pyas-webhooks.ts` and mounts it before `app.use(express.json())`. The route verifies the signature of each delivery against the raw body, which no longer matches once the body is parsed. The agent adds the endpoint to your sandbox with `create_webhook`, at your published address: ```text https://.replit.app/api/pyas/webhooks ``` Use the published address, not the editor preview's, which can change. Store the signing secret that `create_webhook` returns as a secret named `PYAS_WEBHOOK_SECRET`, in the editor and in the published app's secrets. See [Webhooks](/webhooks) for the events Pyas sends. ## Go live 1. Make `currentUserId()` in `server/pyas.ts` return the signed-in person's ID from your app's sign-in. Until then, the routes respond with `401` and the code `SIGN_IN_REQUIRED` to every request made with a live key, so people's calendars never mix. 2. In the Pyas dashboard, choose a plan and create a live key. Plans, payment and live keys are always done by you, in the dashboard. 3. Replace the `PYAS_API_KEY` secret with the live key, in the editor and in the published app's secrets. 4. Add your published address to the app's allowed origins, and add a live webhook endpoint in the dashboard. # Build with v0 ::lead v0 can add Google Calendar and Microsoft Outlook to your app with Pyas. You connect the Pyas MCP server to v0, and v0 gets the code for each task from Pyas while it builds your app. You store a key as an environment variable. The key stays on the server, and the code works with the sandbox's sample people before anyone connects a real calendar. :: ## Add Pyas to v0 1. In v0, open the **+** menu in the prompt box and select **MCPs**. 2. Add a custom server with the address `https://mcp.pyas.io/sandbox`, and choose **OAuth** for authentication. 3. Sign in to Pyas, choose your app, and select **Allow**. See [Sign in](/ai-tools#sign-in) for what each permission allows. v0 uses the Pyas tools while it builds your app. The app it builds calls the Pyas API directly, with your key, and doesn't use the MCP server. ## Ask for what you want Ask in plain words, for example: > Add a button that lets people connect their Google or Outlook calendar, and show their events for the next week. v0 checks your setup with `setup_status` and gets the code for each task with `get_recipe`. It adds your app's preview address to the allowed origins with `update_app`, and creates a test key with `get_test_key`. You confirm the address in Pyas, from the link it gives you. Every response from Pyas ends with the next step. ## Store the key Store the test key as an environment variable named `PYAS_API_KEY`: 1. Open the project menu, then **Settings**, then **Environment Variables**. 2. Add `PYAS_API_KEY`. The preview reads the **Development** variables, and the published app reads the **Production** ones, so add it to each environment where you use it. Route handlers read the variable on the server, and it never reaches the browser. Don't name it with a `NEXT_PUBLIC_` prefix, which would send it to the browser. A test key works only in your app's [sandbox](/sandbox). ## What v0 adds The code runs as Next.js route handlers: ```text lib/pyas.ts the Pyas client, and who is signed in app/api/pyas/connect-session/route.ts creates a connect session for the button app/api/pyas/events/route.ts the person's events for the next 7 days app/api/pyas/book/route.ts books the first free 30 minutes tomorrow components/connect-calendar.tsx the "Connect an account" button ``` Your pages call the routes with `fetch`, for example `fetch('/api/pyas/events')`. With a test key, the routes use the sandbox's sample person, so the events and booking work right away. The connect button shows a message from Pyas that connecting a real calendar requires a live key. This is expected. ## Receive webhooks Ask v0 to receive Pyas webhooks. It adds `app/api/pyas/webhooks/route.ts`, which verifies the signature of each delivery against the raw body before it reads the event. v0 adds the endpoint to your sandbox with `create_webhook`, at your published address: ```text https://.vercel.app/api/pyas/webhooks ``` Store the signing secret that `create_webhook` returns as an environment variable named `PYAS_WEBHOOK_SECRET`. See [Webhooks](/webhooks) for the events Pyas sends. ## Go live 1. Make `currentUserId()` in `lib/pyas.ts` return the signed-in person's ID from your app's sign-in. Until then, the routes respond with `401` and the code `SIGN_IN_REQUIRED` to every request made with a live key, so people's calendars never mix. 2. In the Pyas dashboard, choose a plan and create a live key. Plans, payment and live keys are always done by you, in the dashboard. 3. Replace the `PYAS_API_KEY` Production variable with the live key, and publish again with **Publish Changes**. A changed variable applies only to a new deployment. 4. Add your published address to the app's allowed origins, and add a live webhook endpoint in the dashboard. # Vercel AI SDK ::lead This guide builds a chat in a Next.js app where your users ask about their calendars and book meetings. The assistant uses Pyas's tools for the signed-in user only, and each change waits for them to approve it. :: You need the AI SDK 6 or later. The code here was tested with Next.js 16, the AI SDK 7 and OpenAI's `gpt-5.5`. Any model that calls tools works. See [AI in your app](/ai-in-your-app) for how the tools work. To start from a working app with the code on this page, create it with the Pyas CLI: ```bash npx @pyas/cli init --template nextjs-ai-chat my-chat ``` Set `OPENAI_API_KEY` in its `.env.local`, then run `npm install` and `npm run dev`. If you're signed in with `pyas login`, your test key is already there. ## Install ```bash npm install @pyas/ai-tools ai @ai-sdk/react @ai-sdk/openai ``` Set these on your server: ```bash PYAS_API_KEY=pyas_sk_test_... # a test key from API keys in the Pyas dashboard OPENAI_API_KEY=sk-... ``` The examples use a `currentUserId` function for your sign-in. It returns the signed-in person's ID in your app, the same ID you saved as `subjectId` when they connected their calendar. To try the guide in the sandbox, have it return `'sandbox_user_1'`, the sandbox person Avery Brooks. ## The chat route ```ts [app/api/chat/route.ts] import { openai } from '@ai-sdk/openai' import { convertToModelMessages, stepCountIs, streamText, type UIMessage } from 'ai' import { pyasTools } from '@pyas/ai-tools/vercel' import { currentUserId } from '@/lib/user' export async function POST(req: Request) { const userId = await currentUserId(req) if (!userId) return new Response('Sign in to use your calendar.', { status: 401 }) const { messages }: { messages: UIMessage[] } = await req.json() // Today's date lets the model work out "tomorrow". const today = new Date().toISOString().slice(0, 10) const result = streamText({ model: openai('gpt-5.5'), system: `You help the user manage their calendar. Today is ${today}.`, messages: await convertToModelMessages(messages), tools: pyasTools({ subjectId: userId }), stopWhen: stepCountIs(10), // With OpenAI: each request carries the model's reasoning, so a chat that goes on after // an approval never points at reasoning OpenAI no longer has. providerOptions: { openai: { store: false, include: ['reasoning.encrypted_content'] } }, }) return result.toUIMessageStreamResponse() } ``` `pyasTools({ subjectId: userId })` gives the model the tools for this person's calendars only. Each tool that changes a calendar is marked as needing approval, so the AI SDK stops at it and sends the change to the page instead of running it. With a model from another provider, leave out `providerOptions`. ## The chat page ```tsx [app/page.tsx] 'use client' import { useChat } from '@ai-sdk/react' import { lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai' import { useState } from 'react' import { Approval } from './approval' export default function Chat() { const [input, setInput] = useState('') const { messages, sendMessage, addToolApprovalResponse } = useChat({ // Once the person has answered every change, the chat carries on by itself. sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses, }) // A declined change never runs. The reason tells the model not to ask for it again. function answer(id: string, approved: boolean) { const reason = approved ? undefined : "The user declined this change. Leave it as it is, and don't ask again unless they do." addToolApprovalResponse({ id, approved, reason }) } function send(event: React.FormEvent) { event.preventDefault() sendMessage({ text: input }) setInput('') } return (
{messages.map((message) => (
{message.parts.map((part, index) => { if (part.type === 'text') return
{part.text}
if (part.type.startsWith('tool-') && 'state' in part && part.state === 'approval-requested') { return } onAnswer={(approved) => answer(part.approval.id, approved)} /> } return null })}
))}
setInput(event.target.value)} placeholder="Ask about your calendar" />
) } ``` A tool part in the `approval-requested` state is a change waiting for the person. When they choose, `addToolApprovalResponse` records it, and the chat sends it to your route, where the AI SDK runs the approved change or tells the model it was declined. Pass a `reason` when the person declines. Without one, the model may ask for the same change again. ## Show the change The tool part holds what the model asked for, such as an event ID to delete. To show the person the event itself, ask your server. `describePyasChange` reads the event from Pyas, with the same checks as the tools: ```ts [app/api/pyas-change/route.ts] import { describePyasChange } from '@pyas/ai-tools/vercel' import { currentUserId } from '@/lib/user' /** What a change waiting for approval is about, from Pyas's own data, for the approval card. */ export async function POST(req: Request) { const userId = await currentUserId(req) if (!userId) return new Response('Sign in to use your calendar.', { status: 401 }) const { tool, input } = await req.json() return Response.json(await describePyasChange({ tool, input }, { subjectId: userId })) } ``` It returns `{ ok: true, title, event }`, where `title` says what the change does, such as "Delete an event", and `event` is the event as it is now in Pyas. A new event has no `event`: show the title and times from the input. When the change could never run, such as for an event that doesn't exist, it returns `{ ok: false, error }`. ```tsx [app/approval.tsx] 'use client' import { useEffect, useState } from 'react' interface PyasEvent { title: string; startsAtLocal: string; endsAtLocal: string } interface Change { ok: boolean title?: string event?: PyasEvent error?: { message: string } } const when = (startsAt: string, endsAt: string) => { const start = new Date(startsAt) const time = (date: Date) => date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) return `${start.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}, ${time(start)} to ${time(new Date(endsAt))}` } /** One change waiting for the person: what it is, from Pyas, and their answer. */ export function Approval({ tool, input, onAnswer }: { tool: string; input: Record; onAnswer: (approved: boolean) => void }) { const [change, setChange] = useState(null) useEffect(() => { fetch('/api/pyas-change', { method: 'POST', body: JSON.stringify({ tool, input }) }) .then((res) => res.json()) .then(setChange) }, [tool, input]) if (!change) return
Checking this change...
if (!change.ok) return
This change can't be made: {change.error?.message}
const { event } = change const moves = event && input.startsAt && input.endsAt return (
{change.title}
{event &&
{event.title}
} {event &&
{moves ? 'Now: ' : ''}{when(event.startsAtLocal, event.endsAtLocal)}
} {moves &&
New: {when(input.startsAt, input.endsAt)}
} {!event && input.title &&
{input.title}
} {!event && input.startsAt &&
{when(input.startsAt, input.endsAt)}
}
) } ``` The class names are for your own styles. With some styling, a request to move a meeting looks like this: ::screenshot{src="/images/approval-in-your-app.png" alt="A chat. The person asked to move their 1:1 with Sarah to 4:30 PM. A card shows the event's current time and the new time, with Approve and Decline buttons." width="660" height="399" caption="The current time comes from Pyas. The new time is what the model asked for."} :: ## Try it 1. Run your app with a test key, and with `currentUserId` returning `'sandbox_user_1'`. 2. Ask "What's on my calendar tomorrow?". The assistant reads Avery's calendar without asking. 3. Ask "Book a 30 minute meeting tomorrow afternoon when I'm free." A card asks you to approve the new event. Select **Approve**, and the event is on the calendar. 4. Ask it to delete that meeting, and select **Decline**. The meeting stays, and the assistant says it left it as it was. To go live, see [Go live](/ai-in-your-app#go-live). # VS Code ::lead Add Pyas to GitHub Copilot in VS Code, and its agent can set up your Pyas app, write the code that connects calendars, and check your sandbox while you code. You add Pyas by its address and sign in. There is nothing to install and no key to copy. :: Start with the sandbox address, `https://mcp.pyas.io/sandbox`. It works in your app's [sandbox](/sandbox), on sample people, calendars and events, so nothing you try reaches a real calendar. You need VS Code 1.99 or later. On Copilot Business or Enterprise, an administrator must turn on the **MCP servers in Copilot** policy first. To use Pyas with Claude Code's extension for VS Code instead, see [Claude Code](/claude-code). ## Add Pyas to VS Code Select [Add to VS Code](vscode:mcp/install?%7B%22name%22%3A%22pyas-sandbox%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fmcp.pyas.io%2Fsandbox%22%7D). VS Code opens the server's page, where you install it. Or add it yourself: 1. Open the Command Palette and run **MCP: Add Server**. 2. Choose **HTTP (HTTP or Server-Sent Events)**, and enter `https://mcp.pyas.io/sandbox`. 3. For the server ID, enter `pyas-sandbox`. 4. Choose **Global** to use it in every project. ## Sign in The first time VS Code connects to Pyas, it asks you to sign in, and your browser opens the Pyas dashboard: 1. Sign in, or create an account. 2. Choose the app that VS Code works with. 3. Check what VS Code asks for, and select **Allow**. Go back to VS Code. See [Sign in](/ai-tools#sign-in) for what each permission allows. ## Use it while you code Open the chat, choose **Agent**, and ask in plain words, in the project you're working on: > Set up Pyas in this app so people can connect their Google or Outlook calendar. The agent checks your app's setup with `setup_status`, gets the code for your stack with `get_recipe`, and creates a test key with `get_test_key`. It tells you where to store the key, such as `PYAS_API_KEY` in your `.env` file. Recipes cover Next.js and Express, and the AI app builders. > What's on Avery's calendar tomorrow? > My last request to Pyas failed. Why? The sandbox's sample people, such as Avery Brooks and Riley Chen, have calendars with a week of events. For a failed request, the agent looks it up with `get_request` and `explain_error`, from its trace ID. To choose which Pyas tools the agent can use, select **Configure Tools** in the chat. When Pyas adds a tool, run **MCP: Reset Cached Tools** to see it. Every request VS Code makes is on the **Logs** page in the dashboard, marked with the tool that made it. ## Choose what needs your approval VS Code asks you before the agent uses a Pyas tool. Select **Allow Once**, or **Always Allow** for a tool that only reads, such as listing events. Keep being asked for the tools that change something, at least on the live server. ## Live calendars To work on the real calendars that people connected to your app, add a second server: 1. Run **MCP: Add Server** again, with `https://mcp.pyas.io` and the server ID `pyas-live`. 2. When you sign in, choose whether the agent may only read live calendars, or change them too. Before the agent changes a live calendar, you confirm the change in Pyas, from a link that it gives you. See [Live calendars](/ai-tools#live-calendars) for how that works and who can confirm. While you work in the sandbox, stop the live server: run **MCP: List Servers**, select **pyas-live**, then **Stop Server**. ## Disconnect To remove Pyas from VS Code, run **MCP: List Servers**, select the server, then **Uninstall**. To stop VS Code from Pyas's side, open your profile in the Pyas dashboard. **Connected tools** lists VS Code with when it was last used, and **Disconnect** stops it immediately. To use it again, add it again and sign in. # Accounts ::lead List connected calendar accounts and manage their status, settings and imports, including virtual accounts. :: ::note The examples use IDs from a sample sandbox. Your sandbox has the same people, calendars and events with its own IDs, which `GET /v2/sandbox` returns. When you're signed in to the dashboard, **Try it** fills in your IDs and sends the request to your sandbox, never to live data. :: --- ::section-heading{id="accounts-list" tag="GET" label="/v2/accounts"} List accounts :: ::row :::column Returns the app's connected accounts and virtual accounts, in the order they were added. :try-it{operation="accounts.list"} ### Query parameters ::::properties :::::property{name="subjectId" type="string"} Returns only accounts with this `subjectId`. ::::: :::::property{name="provider" type="string"} One of `google`, `microsoft`, `zoom`, `icloud`, `pyas`. ::::: :::::property{name="status" type="string"} One of `active`, `inactive`. ::::: :::::property{name="limit" type="integer"} The maximum number of results per page, up to 200. From 1 to 200. ::::: :::::property{name="cursor" type="string"} The `nextCursor` value from the previous page. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/accounts"} ```bash [cURL] curl 'https://api.pyas.io/v2/accounts?subjectId=sandbox_user_1' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.accounts.list({ "subjectId": "sandbox_user_1" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": [ { "accountId": "sbx_fda58c5980_1", "provider": "pyas", "email": "sbx_fda58c5980_1@cal.pyas.io", "name": "Avery Brooks", "status": "active", "subjectId": "sandbox_user_1", "scopes": [], "createdAt": "2026-11-14T20:00:23.536Z", "updatedAt": "2026-11-14T20:00:23.536Z" }, { "accountId": "sbx_fda58c5980_google", "provider": "google", "email": "avery.brooks@example.com", "name": "Avery Brooks", "status": "active", "subjectId": "sandbox_user_1", "scopes": [ "https://www.googleapis.com/auth/calendar", "https://www.googleapis.com/auth/calendar.events", "https://www.googleapis.com/auth/drive.file", "https://www.googleapis.com/auth/meetings.space.created", "https://www.googleapis.com/auth/admin.directory.resource.calendar.readonly" ], "createdAt": "2026-11-14T20:00:24.196Z", "updatedAt": "2026-11-14T20:00:24.196Z" }, { "accountId": "sbx_fda58c5980_zoom", "provider": "zoom", "email": "avery.brooks@example.com", "name": "Avery Brooks", "status": "active", "subjectId": "sandbox_user_1", "scopes": [ "meeting:write:meeting", "meeting:read:meeting", "user:read:user" ], "createdAt": "2026-11-14T20:00:24.196Z", "updatedAt": "2026-11-14T20:00:24.196Z" } ], "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": { "nextCursor": null } } ``` :::: ::: :: --- ::section-heading{id="accounts-createvirtual" tag="POST" label="/v2/accounts"} Create a virtual account :: ::row :::column Creates a calendar account that Pyas hosts itself (`provider: pyas`), with a primary calendar. Use virtual accounts for people who don't use Google or Outlook, and for testing. Google and Outlook accounts are connected with connect sessions instead. :try-it{operation="accounts.createVirtual"} ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="provider" type="'pyas'" required} ::::: :::::property{name="name" type="string" required} A virtual account's name (1 to 200 characters). ::::: :::::property{name="subjectId" type="string"} Your own identifier for the account. ::::: :::::property{name="timezone" type="string"} An IANA time zone name. Defaults to `UTC`. ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`BILLING_CONNECTION_LIMIT`](/errors/BILLING_CONNECTION_LIMIT) (402): The plan's connected account limit has been reached. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_LIMIT_REACHED`](/errors/RESOURCE_LIMIT_REACHED) (409): The limit for this resource has been reached. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/accounts"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/accounts' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "provider": "pyas", "name": "Taylor Morgan", "subjectId": "user_123", "timezone": "America/Chicago" }' ``` ```js [Node.js] const result = await pyas.accounts.createVirtual({ "provider": "pyas", "name": "Taylor Morgan", "subjectId": "user_123", "timezone": "America/Chicago" }) ``` :::: ::::code-group{title="Response" label="201 Created"} ```json { "success": true, "data": { "accountId": "ed449ba9-6b5e-461d-9252-0415a35fddb2", "provider": "pyas", "email": "ed449ba9-6b5e-461d-9252-0415a35fddb2@cal.pyas.io", "name": "Taylor Morgan", "status": "active", "subjectId": "user_123", "scopes": [], "createdAt": "2026-11-14T20:00:24.652Z", "updatedAt": "2026-11-14T20:00:24.652Z", "primaryCalendarId": "calm5p8d0crjk4bdjh6bdts" }, "provider": "pyas", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="accounts-get" tag="GET" label="/v2/accounts/{accountId}"} Get an account :: ::row :::column :try-it{operation="accounts.get"} ### Path parameters ::::properties :::::property{name="accountId" type="string" required} The Pyas `accountId` of the connected account. ::::: :::: ### Errors - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/accounts/{accountId}"} ```bash [cURL] curl 'https://api.pyas.io/v2/accounts/sbx_fda58c5980_1' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.accounts.get({ "accountId": "sbx_fda58c5980_1" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "accountId": "sbx_fda58c5980_1", "provider": "pyas", "email": "sbx_fda58c5980_1@cal.pyas.io", "name": "Avery Brooks", "status": "active", "subjectId": "sandbox_user_1", "scopes": [], "createdAt": "2026-11-14T20:00:23.536Z", "updatedAt": "2026-11-14T20:00:23.536Z" }, "provider": "pyas", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="accounts-update" tag="PATCH" label="/v2/accounts/{accountId}"} Update an account :: ::row :::column Sets or clears `subjectId`, your own identifier for the account's user. For a virtual account, you can also update `name`. :try-it{operation="accounts.update"} ### Path parameters ::::properties :::::property{name="accountId" type="string" required} The Pyas `accountId` of the connected account. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="subjectId" type="string | null"} A string to set the identifier, or `null` to clear it. ::::: :::::property{name="name" type="string"} A virtual account's name (1 to 200 characters). ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="PATCH" label="/v2/accounts/{accountId}"} ```bash [cURL] curl -X PATCH 'https://api.pyas.io/v2/accounts/sbx_fda58c5980_2' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "subjectId": "crm_contact_88" }' ``` ```js [Node.js] const result = await pyas.accounts.update({ "accountId": "sbx_fda58c5980_2", "subjectId": "crm_contact_88" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "accountId": "sbx_fda58c5980_2", "provider": "pyas", "email": "sbx_fda58c5980_2@cal.pyas.io", "name": "Sam Patel", "status": "active", "subjectId": "crm_contact_88", "scopes": [], "createdAt": "2026-11-14T20:00:24.847Z", "updatedAt": "2026-11-14T20:00:25.728Z" }, "provider": "pyas", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="accounts-delete" tag="DELETE" label="/v2/accounts/{accountId}"} Delete an account :: ::row :::column Deletes an account from your app and sends a `connection.deleted` webhook. For a Google, Outlook or Zoom account, Pyas deletes the credentials it stores for the account and stops watching it for changes. The user's calendars at the provider are not changed, and the user can connect the account again later. With a live key, the account no longer counts toward your plan. A virtual account is deleted with all of its calendars and events. For every account, the metadata you stored on its events and calendars is deleted too. In the sandbox, resetting the sandbox restores deleted accounts. **In the sandbox:** This example deletes the simulated Google account in the sandbox. Virtual accounts are deleted the same way. Resetting the sandbox with `POST /v2/sandbox/reset` restores deleted sandbox accounts. :try-it{operation="accounts.delete"} ### Path parameters ::::properties :::::property{name="accountId" type="string" required} The Pyas `accountId` of the connected account. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Errors - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="DELETE" label="/v2/accounts/{accountId}"} ```bash [cURL] curl -X DELETE 'https://api.pyas.io/v2/accounts/sbx_fda58c5980_google' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.accounts.delete({ "accountId": "sbx_fda58c5980_google" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": null, "provider": "pyas", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="accounts-import" tag="POST" label="/v2/accounts/import"} Import accounts :: ::row :::column Imports accounts from another calendar API or from your own integration, so your users don't have to connect their calendars again. Send up to 100 refresh tokens issued to your app's own Google, Microsoft or Zoom OAuth app (Native Auth). Pyas verifies each token with the provider and saves the account. If the account already exists, Pyas updates it with the new tokens. The import runs in the background. **In the sandbox:** An import connects real accounts, so it requires a live key. In the sandbox, this request returns `AUTH_SANDBOX_MISMATCH`. :try-it{operation="accounts.import"} ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="accounts" type="array of object" required} At most 100 items. ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_SANDBOX_MISMATCH`](/errors/AUTH_SANDBOX_MISMATCH) (403): A test key was used with live data, or a live key was used with sandbox data. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_PROVIDER_NOT_CONFIGURED`](/errors/CONNECTION_PROVIDER_NOT_CONFIGURED) (422): The app is not configured to connect accounts from this provider. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/accounts/import"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/accounts/import' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "accounts": [ { "provider": "google", "refreshToken": "1//0g-your-refresh-token", "subjectId": "user_123" } ] }' ``` ```js [Node.js] const result = await pyas.accounts.import({ "accounts": [ { "provider": "google", "refreshToken": "1//0g-your-refresh-token", "subjectId": "user_123" } ] }) ``` :::: ::::code-group{title="Response" label="202 Accepted"} ```json { "success": true, "data": { "id": "aimp_36c11c55818fa0cad686b7d8", "status": "queued", "total": 1, "counts": { "pending": 1, "created": 0, "updated": 0, "failed": 0 }, "items": [ { "index": 0, "provider": "google", "subjectId": "user_123", "status": "pending", "accountId": null, "email": null, "error": null } ], "createdAt": "2026-11-14T20:00:32.783Z", "finishedAt": null }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="accounts-imports-list" tag="GET" label="/v2/accounts/imports"} List account imports :: ::row :::column :try-it{operation="accounts.imports.list"} ### Query parameters ::::properties :::::property{name="limit" type="integer"} The number of imports to return, newest first. Defaults to 20. From 1 to 100. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/accounts/imports"} ```bash [cURL] curl 'https://api.pyas.io/v2/accounts/imports' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.accounts.imports.list() ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": [], "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="accounts-status-list" tag="GET" label="/v2/accounts/status"} Get the status of several accounts :: ::row :::column Returns the status of each account. `overall.status` is `healthy`, `attention` or `broken`, and `overall` includes the reason and the fix. The response also shows which features are available, and includes a message for the account's owner when they need to take action. `meta.needsAttention` is the number of accounts in the response whose `overall.status` is not `healthy`. With `accountIds` or `subjectId`, returns those accounts. Without them, returns every account of the app in the order they were added, one page at a time. To find every account that needs fixing, send `overall=attention,broken`. A page can include fewer than `limit` accounts even when `meta.nextCursor` is set, so keep requesting pages until `meta.nextCursor` is `null`. `check` works only with `accountIds` or `subjectId`. **In the sandbox:** Lists every account in the app that needs action. In a new sandbox, every account works, so the list is empty. To check particular accounts instead, send `accountIds` or `subjectId`. :try-it{operation="accounts.status.list"} ### Query parameters ::::properties :::::property{name="accountIds" type="string"} The Pyas `accountId` values of up to 20 connected accounts. Send a comma-separated list or repeat the parameter. ::::: :::::property{name="subjectId" type="string"} Selects every connected account labeled with this `subjectId`. ::::: :::::property{name="provider" type="string"} Limits `accountIds` or `subjectId` to accounts on this provider. One of `google`, `microsoft`, `pyas`. ::::: :::::property{name="overall" type="string"} Returns only accounts whose `overall.status` is one of these values, for example `attention,broken`. Works only without `accountIds` or `subjectId`. Accepts a comma-separated list of any of these values: healthy, attention, broken. ::::: :::::property{name="limit" type="integer"} The maximum number of accounts in one page, from 1 to 200. Defaults to 50. Works only without `accountIds` or `subjectId`. From 1 to 200. ::::: :::::property{name="cursor" type="string"} The `nextCursor` from the previous page. ::::: :::::property{name="check" type="boolean"} Sends one request to the provider immediately, bypassing the cache, and includes the result. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/accounts/status"} ```bash [cURL] curl 'https://api.pyas.io/v2/accounts/status?overall=attention%2Cbroken' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.accounts.status.list({ "overall": "attention,broken" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": [], "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": { "needsAttention": 0, "nextCursor": null } } ``` :::: ::: :: --- ::section-heading{id="accounts-settings-get" tag="GET" label="/v2/accounts/{accountId}/settings"} Get calendar settings :: ::row :::column Returns the person's time zone, locale, week start, time and date format, default event length (Google) and working hours (Outlook). A setting is `null` when the provider doesn't support it. :try-it{operation="accounts.settings.get"} ### Path parameters ::::properties :::::property{name="accountId" type="string" required} The Pyas `accountId` of the connected account. ::::: :::: ### Errors - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/accounts/{accountId}/settings"} ```bash [cURL] curl 'https://api.pyas.io/v2/accounts/sbx_fda58c5980_google/settings' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.accounts.settings.get({ "accountId": "sbx_fda58c5980_google" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "timezone": "America/New_York", "locale": "en", "weekStart": "sunday", "timeFormat": "12h", "dateOrder": "MDY", "defaultEventMinutes": 60, "workingHours": null }, "provider": "google", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="accounts-settings-update" tag="PATCH" label="/v2/accounts/{accountId}/settings"} Update calendar settings :: ::row :::column Updates the time zone, time format and working hours (Outlook). The Google Calendar API doesn't support updating settings, so requests for Google accounts return `VALIDATION_UNSUPPORTED_OPERATION`. **In the sandbox:** You can update settings on Outlook accounts. The Google Calendar API supports only reading settings. :try-it{operation="accounts.settings.update"} ### Path parameters ::::properties :::::property{name="accountId" type="string" required} The Pyas `accountId` of the connected account. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="timezone" type="string"} An IANA time zone name, such as America/New_York. ::::: :::::property{name="timeFormat" type="string"} One of `12h`, `24h`. ::::: :::::property{name="workingHours" type="object"} ::::: :::::property{name="workingHours.days" type="array of string" required} One of `sunday`, `monday`, `tuesday`, `wednesday`, `thursday`, `friday`, `saturday`. ::::: :::::property{name="workingHours.start" type="string" required} ::::: :::::property{name="workingHours.end" type="string" required} ::::: :::::property{name="workingHours.timezone" type="string" required} ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="PATCH" label="/v2/accounts/{accountId}/settings"} ```bash [cURL] curl -X PATCH 'https://api.pyas.io/v2/accounts/sbx_fda58c5980_outlook/settings' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "timezone": "Europe/Paris", "timeFormat": "24h" }' ``` ```js [Node.js] const result = await pyas.accounts.settings.update({ "accountId": "sbx_fda58c5980_outlook", "timezone": "Europe/Paris", "timeFormat": "24h" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "timezone": "Europe/Paris", "locale": "en-GB", "weekStart": null, "timeFormat": "24h", "dateOrder": null, "defaultEventMinutes": null, "workingHours": { "days": [ "monday", "tuesday", "wednesday", "thursday", "friday" ], "start": "09:00", "end": "17:30", "timezone": "Europe/London" } }, "provider": "microsoft", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="accounts-status-get" tag="GET" label="/v2/accounts/{accountId}/status"} Get the status of an account :: ::row :::column Returns the status of the account. `state` shows whether the account is connected, and `health` is based on its recent provider requests. The response also shows whether each feature is available, which permissions are missing and what the provider supports, and includes a message for the account's owner when they need to take action. :try-it{operation="accounts.status.get"} ### Path parameters ::::properties :::::property{name="accountId" type="string" required} The Pyas `accountId` of the connected account. ::::: :::: ### Query parameters ::::properties :::::property{name="check" type="boolean"} Sends one request to the provider immediately, bypassing the cache, and includes the result. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/accounts/{accountId}/status"} ```bash [cURL] curl 'https://api.pyas.io/v2/accounts/sbx_fda58c5980_1/status?check=true' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.accounts.status.get({ "accountId": "sbx_fda58c5980_1", "check": "true" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "accountId": "sbx_fda58c5980_1", "provider": "pyas", "name": "Avery Brooks", "email": "sbx_fda58c5980_1@cal.pyas.io", "subjectId": "sandbox_user_1", "state": "connected", "health": { "status": "unknown", "lastSuccessAt": null, "lastFailure": null }, "scopes": { "recorded": [], "source": "requestedAtConnect" }, "features": { "readEvents": { "available": true }, "writeEvents": { "available": true }, "manageCalendars": { "available": true }, "freeBusy": { "available": true }, "sync": { "available": true }, "sharing": { "available": false, "reason": "Virtual calendars are shared through their subscription feed, not with individual people." }, "settings": { "available": true }, "changeSettings": { "available": true }, "categories": { "available": true }, "meetingSettings": { "available": false, "reason": "Virtual calendars use Zoom links created through a connected Zoom account. They have no meeting settings of their own." }, "attachmentUploads": { "available": false, "reason": "Virtual calendars store links, not files." }, "rooms": { "available": false, "reason": "Virtual accounts have no room directory." } }, "capabilities": { "incrementalSync": true, "freeBusy": true, "batch": false, "nativeMetadata": false, "calendarWrite": true, "maxAttachmentBytes": 0, "push": { "supported": false, "maxTtlSeconds": 0 } }, "userMessage": null, "fixUrl": null, "overall": { "status": "healthy", "reason": null, "fix": null }, "check": { "ok": true, "at": "2026-11-14T20:00:24.415Z" } }, "provider": "pyas", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="accounts-workinglocations-list" tag="GET" label="/v2/accounts/{accountId}/working-locations"} List working locations :: ::row :::column Returns where the person works each day, from Google's working location events (home, office or elsewhere). The window can span at most 92 days. :try-it{operation="accounts.workingLocations.list"} ### Path parameters ::::properties :::::property{name="accountId" type="string" required} The Pyas `accountId` of the connected account. ::::: :::: ### Query parameters ::::properties :::::property{name="from" type="string (date-time)"} An ISO 8601 time with a zone. ::::: :::::property{name="to" type="string (date-time)"} An ISO 8601 time with a zone. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/accounts/{accountId}/working-locations"} ```bash [cURL] curl 'https://api.pyas.io/v2/accounts/sbx_fda58c5980_google/working-locations?from=2026-11-14T00%3A00%3A00Z&to=2026-11-28T00%3A00%3A00Z' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.accounts.workingLocations.list({ "accountId": "sbx_fda58c5980_google", "from": "2026-11-14T00:00:00Z", "to": "2026-11-28T00:00:00Z" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": [ { "eventId": "sgcd2d8976578edb2f608c814d", "startsAt": "2026-11-15T05:00:00Z", "endsAt": "2026-11-16T05:00:00Z", "isAllDay": true, "location": { "type": "office", "label": "HQ" } }, { "eventId": "sg69e86df5bfd6e93979271e83", "startsAt": "2026-11-16T05:00:00Z", "endsAt": "2026-11-17T05:00:00Z", "isAllDay": true, "location": { "type": "home", "label": null } } ], "provider": "google", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="accounts-imports-get" tag="GET" label="/v2/accounts/imports/{importId}"} Get an account import :: ::row :::column **In the sandbox:** Use the ID returned by `POST /v2/accounts/import`. :try-it{operation="accounts.imports.get"} ### Path parameters ::::properties :::::property{name="importId" type="string" required} ::::: :::: ### Errors - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/accounts/imports/{importId}"} ```bash [cURL] curl 'https://api.pyas.io/v2/accounts/imports/aimp_0123456789abcdef01234567' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.accounts.imports.get({ "importId": "aimp_0123456789abcdef01234567" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "id": "aimp_36c11c55818fa0cad686b7d8", "status": "done", "total": 1, "counts": { "pending": 0, "created": 1, "updated": 0, "failed": 0 }, "items": [ { "index": 0, "provider": "google", "subjectId": "user_123", "status": "created", "accountId": "00d34c15-3251-490a-a8ff-01c8ce8de35d", "email": "refresh@example.test", "error": null } ], "createdAt": "2026-11-14T20:00:32.783Z", "finishedAt": "2026-11-14T20:00:32.879Z" }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="accounts-imports-retry" tag="POST" label="/v2/accounts/imports/{importId}/retry"} Retry an account import :: ::row :::column Retries the accounts that failed with an error that may since have been resolved, such as an unavailable provider or a plan that had reached its connected account limit. **In the sandbox:** Use the ID returned by `POST /v2/accounts/import`. :try-it{operation="accounts.imports.retry"} ### Path parameters ::::properties :::::property{name="importId" type="string" required} ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Errors - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`RESOURCE_CONFLICT`](/errors/RESOURCE_CONFLICT) (409): The resource was modified or already exists. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/accounts/imports/{importId}/retry"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/accounts/imports/aimp_0123456789abcdef01234567/retry' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.accounts.imports.retry({ "importId": "aimp_0123456789abcdef01234567" }) ``` :::: ::::code-group{title="Response" label="202 Accepted"} ```json { "success": true, "data": { "id": "aimp_36766aa0cea9fddd52b0fb7d", "status": "queued", "total": 1, "counts": { "pending": 1, "created": 0, "updated": 0, "failed": 0 }, "items": [ { "index": 0, "provider": "google", "subjectId": "user_456", "status": "pending", "accountId": null, "email": null, "error": null } ], "createdAt": "2026-11-14T20:00:32.884Z", "finishedAt": null }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: # Connect ::lead Connect and reconnect calendar accounts with connect sessions, including the steps that run in the user's browser. :: ::note The examples use IDs from a sample sandbox. Your sandbox has the same people, calendars and events with its own IDs, which `GET /v2/sandbox` returns. When you're signed in to the dashboard, **Try it** fills in your IDs and sends the request to your sandbox, never to live data. :: --- ::section-heading{id="connect-accounts-create" tag="POST" label="/v2/connect/accounts"} Connect an account with a code :: ::row :::column Exchanges the code for tokens, saves the account and returns it. This is the second step of connecting an account from your server without PyasConnect, and it replaces v1's `POST /{provider}/connect`. Send the `code` that the provider returned. If the same person connects an account that is already connected, Pyas reconnects it and the account keeps its `accountId`. **In the sandbox:** Send the `code` that your Redirect URL (Pyas Auth) or your own callback URL (Native Auth) received after `GET /v2/connect/oauth`. This request requires a live key. :try-it{operation="connect.accounts.create"} ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="provider" type="string" required} The provider in `GET /v2/connect/oauth`. One of `google`, `microsoft`, `zoom`. ::::: :::::property{name="code" type="string" required} The `code` that the provider returned to your Redirect URL or callback URL. ::::: :::::property{name="subjectId" type="string"} Your own identifier for the user, stored on the account. ::::: :::::property{name="features" type="array of string"} The same features as in `GET /v2/connect/oauth`. One of `manageCalendars`, `sharing`, `meetingSettings`, `attachmentUploads`, `rooms`. ::::: :::::property{name="redirectUrl" type="string"} The same `redirectUrl` as in `GET /v2/connect/oauth`, if you sent one. ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`BILLING_CONNECTION_LIMIT`](/errors/BILLING_CONNECTION_LIMIT) (402): The plan's connected account limit has been reached. - [`AUTH_SANDBOX_MISMATCH`](/errors/AUTH_SANDBOX_MISMATCH) (403): A test key was used with live data, or a live key was used with sandbox data. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_PROVIDER_NOT_CONFIGURED`](/errors/CONNECTION_PROVIDER_NOT_CONFIGURED) (422): The app is not configured to connect accounts from this provider. - [`CONNECTION_CONSENT_FAILED`](/errors/CONNECTION_CONSENT_FAILED) (422): The provider did not complete the connection. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/connect/accounts"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/connect/accounts' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "provider": "google", "code": "4/0AeanS0Z...", "subjectId": "user_123" }' ``` ```js [Node.js] const result = await pyas.connect.accounts.create({ "provider": "google", "code": "4/0AeanS0Z...", "subjectId": "user_123" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "accountId": "ef66ec55-7f3c-44f8-8e6d-3e8bc2fafc4b", "provider": "google", "email": "avery.brooks@example.com", "name": "Avery Brooks", "subjectId": "user_123", "existing": true, "grantedScopes": null }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="connect-authorize" tag="POST" label="/v2/connect/authorize"} Get the consent URL (popup mode) :: ::row :::column Returns the provider's consent URL to open in a popup. This is the second step. Call it from your page with the session's token, from one of the app's allowed origins. For a session created with a test key, the URL is a Pyas sandbox page instead of the provider's. Requesting a new URL, for example after the user switches provider, invalidates the previous one. **In the sandbox:** Call this from your page with a session's token. Create the session on your server with `POST /v2/connect/sessions`. :try-it{operation="connect.authorize"} ### Body ::::properties :::::property{name="token" type="string" required} The session's token. ::::: :::::property{name="provider" type="string"} `google`, `microsoft` or `zoom`. Must be one of the session's providers. Optional when the session offers only one provider. ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_CONNECT_SESSION`](/errors/AUTH_INVALID_CONNECT_SESSION) (401): The connect session is missing, invalid, expired or already used. - [`AUTH_ORIGIN_NOT_ALLOWED`](/errors/AUTH_ORIGIN_NOT_ALLOWED) (403): The request's origin is not in the app's allowed origins. - [`CONNECTION_PROVIDER_NOT_CONFIGURED`](/errors/CONNECTION_PROVIDER_NOT_CONFIGURED) (422): The app is not configured to connect accounts from this provider. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/connect/authorize"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/connect/authorize' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "token": "pcs_your-session-token", "provider": "google" }' ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "provider": "google", "url": "https://accounts.google.com/o/oauth2/v2/auth?client_id=1234567890-pyas.apps.googleusercontent.com&redirect_uri=https%3A%2F%2Fapi.pyas.io%2Foauth%2Fgoogle%2Fcallback&response_type=code&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile+openid+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.events+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.freebusy+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.events.freebusy+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.calendarlist.readonly+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.calendars.readonly+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.settings.readonly&access_type=offline&prompt=consent&include_granted_scopes=true&state=app_id%3D5c1e9a47-2d3b-4f86-a0e9-7b4c2d18f365_rest%3Dpcs1.cs_99bec7442c4db29a8539d679.9Z_srW0El4rA9gop9VssvA", "expiresAt": "2026-11-14T20:10:32.621Z" }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="connect-complete" tag="POST" label="/v2/connect/complete"} Complete a connection :: ::row :::column Exchanges the code that the provider returned and saves the account. This is the third step. Send the `code` and `state` that the provider returned. For a session created with a test key, the code comes from the Pyas sandbox page and the account is a simulated one in your sandbox. Each session can be completed only once. **In the sandbox:** Send the `code` and `state` that the provider returns after sign-in. :try-it{operation="connect.complete"} ### Body ::::properties :::::property{name="token" type="string" required} The session's token. ::::: :::::property{name="code" type="string" required} The `code` that the provider returned. ::::: :::::property{name="state" type="string" required} The `state` that the provider returned. ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_CONNECT_SESSION`](/errors/AUTH_INVALID_CONNECT_SESSION) (401): The connect session is missing, invalid, expired or already used. - [`BILLING_CONNECTION_LIMIT`](/errors/BILLING_CONNECTION_LIMIT) (402): The plan's connected account limit has been reached. - [`AUTH_ORIGIN_NOT_ALLOWED`](/errors/AUTH_ORIGIN_NOT_ALLOWED) (403): The request's origin is not in the app's allowed origins. - [`RESOURCE_LIMIT_REACHED`](/errors/RESOURCE_LIMIT_REACHED) (409): The limit for this resource has been reached. - [`CONNECTION_CONSENT_FAILED`](/errors/CONNECTION_CONSENT_FAILED) (422): The provider did not complete the connection. - [`CONNECTION_PROVIDER_NOT_CONFIGURED`](/errors/CONNECTION_PROVIDER_NOT_CONFIGURED) (422): The app is not configured to connect accounts from this provider. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/connect/complete"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/connect/complete' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "token": "pcs_your-session-token", "code": "the-code-from-the-provider", "state": "the-state-from-the-provider" }' ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "accountId": "ef66ec55-7f3c-44f8-8e6d-3e8bc2fafc4b", "provider": "google", "email": "avery.brooks@example.com", "name": "Avery Brooks", "subjectId": "user_123", "existing": false, "grantedScopes": null }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="connect-go" tag="GET" label="/v2/connect/go"} Start a connection (redirect mode) :: ::row :::column The `url` of a redirect-mode session. It redirects the user to the provider's consent page, after showing a provider chooser when the session offers more than one provider. For a session created with a test key, it redirects to a Pyas sandbox page instead. This is a page for browsers, not an API endpoint. **In the sandbox:** The `url` of a redirect-mode session, opened in a browser. :try-it{operation="connect.go"} ### Query parameters ::::properties :::::property{name="token" type="string" required} The session's token, included in its `url`. ::::: :::::property{name="provider" type="string"} The provider to use: `google`, `microsoft` or `zoom`. Sending it skips the provider chooser. ::::: :::::property{name="locale" type="string"} The language of the pages: `en`, `nb`, `sv`, `da`, `de`, `fr`, `es`, `nl` or `pt-BR`. If you leave it out, the browser's language is used, then English. ::::: :::: ### Errors - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/connect/go"} ```bash [cURL] curl 'https://api.pyas.io/v2/connect/go?token=pcs_your-session-token' \ -H "x-api-key: $PYAS_API_KEY" ``` :::: ::: :: --- ::section-heading{id="connect-info" tag="POST" label="/v2/connect/info"} Get connect widget details :: ::row :::column Returns the details that the connect widget displays before the user signs in. These include your app's name and icon from the dashboard, the providers the session offers and, for a reconnect, the account to reconnect. Call it from your page with the session's token, from one of the app's allowed origins. This request has no side effects. **In the sandbox:** Call this from your page with a session's token, before the user signs in. Create the session on your server with `POST /v2/connect/sessions`. :try-it{operation="connect.info"} ### Body ::::properties :::::property{name="token" type="string" required} The session's token. ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_CONNECT_SESSION`](/errors/AUTH_INVALID_CONNECT_SESSION) (401): The connect session is missing, invalid, expired or already used. - [`AUTH_ORIGIN_NOT_ALLOWED`](/errors/AUTH_ORIGIN_NOT_ALLOWED) (403): The request's origin is not in the app's allowed origins. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/connect/info"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/connect/info' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "token": "pcs_your-session-token" }' ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "app": { "name": "Acme Scheduler", "iconUrl": null }, "providers": [ "google", "microsoft" ], "reconnect": null, "loginHint": null, "expiresAt": "2026-11-14T20:10:32.621Z" }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="connect-oauth" tag="GET" label="/v2/connect/oauth"} Get a sign-in URL :: ::row :::column Returns the provider's sign-in URL. This is the first step of connecting an account from your server without PyasConnect, and it replaces v1's `GET /{provider}/oauth`. Send the user to `url`. With Pyas Auth, the user returns to the Redirect URL you set for this provider under Provider setup, with `code` and your `state`. With Native Auth, the user returns to your own callback URL. Then send the code to `POST /v2/connect/accounts`. **In the sandbox:** Connecting signs a real person in to Google, Outlook or Zoom, so it requires a live key. To test connecting in the sandbox, use PyasConnect with a session from `POST /v2/connect/sessions`, or create virtual accounts with `POST /v2/accounts`. :try-it{operation="connect.oauth"} ### Query parameters ::::properties :::::property{name="provider" type="string" required} `google`, `microsoft` or `zoom`. One of `google`, `microsoft`, `zoom`. ::::: :::::property{name="state" type="string"} Your own value, which is returned to you with the code. For example, use it to identify which of your users is signing in. ::::: :::::property{name="loginHint" type="string"} The email address to suggest when the user signs in. ::::: :::::property{name="features" type="string"} Additional permissions to request. Accepts a comma-separated list of any of these values: manageCalendars, sharing, meetingSettings, attachmentUploads, rooms. ::::: :::::property{name="redirectUrl" type="string"} The callback URL to return the user to, instead of the one under Provider setup. Works only with Native Auth, and your OAuth app must list this URL as a redirect URI. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_SANDBOX_MISMATCH`](/errors/AUTH_SANDBOX_MISMATCH) (403): A test key was used with live data, or a live key was used with sandbox data. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_PROVIDER_NOT_CONFIGURED`](/errors/CONNECTION_PROVIDER_NOT_CONFIGURED) (422): The app is not configured to connect accounts from this provider. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/connect/oauth"} ```bash [cURL] curl 'https://api.pyas.io/v2/connect/oauth?provider=google&state=user_123&loginHint=person%40example.com' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.connect.oauth({ "provider": "google", "state": "user_123", "loginHint": "person@example.com" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "provider": "google", "url": "https://accounts.google.com/o/oauth2/v2/auth?client_id=1234567890-pyas.apps.googleusercontent.com&redirect_uri=https%3A%2F%2Fapi.pyas.io%2Foauth%2Fgoogle%2Fcallback&response_type=code&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile+openid+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.events+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.freebusy+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.events.freebusy+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.calendarlist.readonly+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.calendars.readonly+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.settings.readonly&access_type=offline&prompt=consent&include_granted_scopes=true&state=app_id%3D5c1e9a47-2d3b-4f86-a0e9-7b4c2d18f365_rest%3Duser_123&login_hint=person%40example.com" }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="connect-sessions-create" tag="POST" label="/v2/connect/sessions"} Create a connect session :: ::row :::column Creates a session for connecting a calendar account. This is the first step of connecting with PyasConnect. Call it from your server. With a live key, the user signs in to Google, Microsoft or Zoom and a real account is connected. With a test key, the user sees a Pyas sandbox page instead of the provider's sign-in, and a simulated account is connected in your sandbox, so you can build and test the whole flow before going live. In popup mode, pass the `token` to your page. In redirect mode, set a `returnUrl` and send the user to `url`. A session is valid for 10 minutes and connects one account. **In the sandbox:** Pass the `token` to PyasConnect on your page. With a test key, PyasConnect opens a Pyas sandbox page instead of Google or Microsoft, and connects a simulated account in your sandbox. For redirect mode, add a `returnUrl` on one of your app's allowed origins. To reconnect a broken account, send its `accountId` instead of `providers` and `subjectId`. :try-it{operation="connect.sessions.create"} ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="accountId" type="string"} The `accountId` of an existing account to reconnect. PyasConnect opens directly to the account's provider, and only the same person (with the same email address) can sign in. The account keeps its `accountId`. Leave out `providers`, `subjectId` and `loginHint`, because they are taken from the account. ::::: :::::property{name="providers" type="array of string"} The providers to offer the user. Defaults to `google` and `microsoft`. Zoom is offered only when you include `zoom`. One of `google`, `microsoft`, `zoom`. ::::: :::::property{name="products" type="array of 'calendar'"} What the connection is used for. Currently only `calendar` is supported. ::::: :::::property{name="features" type="array of string"} Additional permissions to request: `manageCalendars`, `sharing`, `meetingSettings`, `attachmentUploads` or `rooms`. With Pyas Auth, Google `manageCalendars`, `sharing`, `meetingSettings`, `attachmentUploads` and `rooms` need Native Auth for now. One of `manageCalendars`, `sharing`, `meetingSettings`, `attachmentUploads`, `rooms`. ::::: :::::property{name="subjectId" type="string"} Your own identifier for the connected account. ::::: :::::property{name="loginHint" type="string"} The email address to suggest when the user signs in. ::::: :::::property{name="returnUrl" type="string"} For redirect mode, the URL to send the user to when the flow ends. Must use HTTPS and be on one of the app's allowed origins. ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_ORIGIN_NOT_ALLOWED`](/errors/AUTH_ORIGIN_NOT_ALLOWED) (403): The request's origin is not in the app's allowed origins. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`CONNECTION_PROVIDER_NOT_CONFIGURED`](/errors/CONNECTION_PROVIDER_NOT_CONFIGURED) (422): The app is not configured to connect accounts from this provider. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/connect/sessions"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/connect/sessions' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "providers": [ "google", "microsoft" ], "subjectId": "user_123" }' ``` ```js [Node.js] const result = await pyas.connect.sessions.create({ "providers": [ "google", "microsoft" ], "subjectId": "user_123" }) ``` :::: ::::code-group{title="Response" label="201 Created"} ```json { "success": true, "data": { "id": "cs_99bec7442c4db29a8539d679", "status": "pending", "mode": "popup", "reconnectAccountId": null, "providers": [ "google", "microsoft" ], "products": [ "calendar" ], "features": [], "subjectId": "user_123", "returnUrl": null, "provider": null, "expiresAt": "2026-11-14T20:10:32.621Z", "account": null, "error": null, "token": "pcs_example", "url": null }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="connect-sessions-get" tag="GET" label="/v2/connect/sessions/{id}"} Get a connect session :: ::row :::column Returns the session's status and the account it connected. In redirect mode, confirm the result with this endpoint rather than relying on the query string. **In the sandbox:** Use the ID returned by `POST /v2/connect/sessions`, with the same kind of key: a session created with a test key is in the sandbox. :try-it{operation="connect.sessions.get"} ### Path parameters ::::properties :::::property{name="id" type="string" required} ::::: :::: ### Errors - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/connect/sessions/{id}"} ```bash [cURL] curl 'https://api.pyas.io/v2/connect/sessions/cs_0123456789abcdef01234567' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.connect.sessions.get({ "id": "cs_0123456789abcdef01234567" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "id": "cs_99bec7442c4db29a8539d679", "status": "pending", "mode": "popup", "reconnectAccountId": null, "providers": [ "google", "microsoft" ], "products": [ "calendar" ], "features": [], "subjectId": "user_123", "returnUrl": null, "provider": null, "expiresAt": "2026-11-14T20:10:32.621Z", "account": null, "error": null }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: # Calendars ::lead Manage calendars, calendar sharing, categories, feeds and calendar groups. :: ::note The examples use IDs from a sample sandbox. Your sandbox has the same people, calendars and events with its own IDs, which `GET /v2/sandbox` returns. When you're signed in to the dashboard, **Try it** fills in your IDs and sends the request to your sandbox, never to live data. :: --- ::section-heading{id="calendargroups-list" tag="GET" label="/v2/calendar-groups"} List calendar groups :: ::row :::column Calendar groups are Outlook folders that contain calendars. Google Calendar doesn't have calendar groups, so requests for Google accounts are rejected. :try-it{operation="calendarGroups.list"} ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/calendar-groups"} ```bash [cURL] curl 'https://api.pyas.io/v2/calendar-groups?accountId=sbx_fda58c5980_outlook' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.calendarGroups.list({ "accountId": "sbx_fda58c5980_outlook" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": [ { "id": "AAMkAPtDnBoVMLgLIu-5ZykUH3m3F8jh-vhqP8YlJhgiZLHEAAA=", "name": "My Calendars", "calendarIds": [ "AAMkAU662pGNAu0JgQ5Fpjf1wipv7j82pYQuBvDbbTPOq8h4AAA=" ] }, { "id": "AAMkAyCWrTohk9tAZoyL6-kuuLYadpDmD-3sFfQAb6CXpof0AAA=", "name": "Other calendars", "calendarIds": [ "AAMkAPZCx8r3fRaim7oP6DyRH-4h12pnbtU0iizh-r7n7VpQAAA=" ] }, { "id": "AAMkAFDoRdCjiMqrQ0gUzJhGn7fWP7a-kYNGIymOmCmTrT7cAAA=", "name": "Clients", "calendarIds": [ "AAMkA-M9ePPI-lDjuHutrLL7ee-FjiW4n60cpd6GPnyDTv84AAA=" ] }, { "id": "AAMkAYE1Kc3zrnSLkXx5fXHBEPOfQSybhu8nRccpNiIpXWVwAAA=", "name": "Projects", "calendarIds": [] } ], "provider": "microsoft", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="calendargroups-create" tag="POST" label="/v2/calendar-groups"} Create a calendar group :: ::row :::column Calendar groups are Outlook folders that contain calendars. Google Calendar doesn't have calendar groups, so requests for Google accounts are rejected. **In the sandbox:** Calendar groups are an Outlook feature. For Google accounts, the API returns an error explaining that Google doesn't support them. :try-it{operation="calendarGroups.create"} ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. You can send it here or in the query. ::::: :::::property{name="name" type="string" required} ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/calendar-groups"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/calendar-groups?accountId=sbx_fda58c5980_outlook' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "name": "Partners" }' ``` ```js [Node.js] const result = await pyas.calendarGroups.create({ "accountId": "sbx_fda58c5980_outlook", "name": "Partners" }) ``` :::: ::::code-group{title="Response" label="201 Created"} ```json { "success": true, "data": { "id": "AAMkAkhQyt3s2NcmZzvgLwdE68IZ07bC7gBnR6Qr6TyKO3qYAAA=", "name": "Partners", "calendarIds": [] }, "provider": "microsoft", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="calendars-list" tag="GET" label="/v2/calendars"} List calendars :: ::row :::column Returns the calendars of one connected account, one page at a time. With `accountIds` or `subjectId`, returns every calendar of each account in one response, grouped by account. :try-it{operation="calendars.list"} ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::::property{name="limit" type="integer"} The maximum number of results in one page. From 1 to 250. ::::: :::::property{name="cursor" type="string"} The `nextCursor` from the previous page. ::::: :::::property{name="accountIds" type="string"} The Pyas `accountId` values of up to 20 connected accounts. Send a comma-separated list or repeat the parameter. ::::: :::::property{name="subjectId" type="string"} Selects every connected account labeled with this `subjectId`. ::::: :::::property{name="provider" type="string"} Limits `accountIds` or `subjectId` to accounts on this provider. One of `google`, `microsoft`, `pyas`. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) (422): The user revoked access, or the provider no longer accepts the stored credentials. - [`CONNECTION_TOKEN_EXPIRED`](/errors/CONNECTION_TOKEN_EXPIRED) (422): The provider's access token expired and could not be refreshed. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. - [`PROVIDER_TIMEOUT`](/errors/PROVIDER_TIMEOUT) (504): The calendar provider did not respond in time. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/calendars"} ```bash [cURL] curl 'https://api.pyas.io/v2/calendars?accountId=sbx_fda58c5980_1' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.calendars.list({ "accountId": "sbx_fda58c5980_1" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": [ { "id": "calfda58c59801p", "name": "Avery Brooks", "description": null, "timezone": "America/New_York", "isPrimary": true, "color": null, "accessRole": "owner", "location": null, "conferencing": { "default": null, "allowed": [ "zoom" ] }, "tallyResponses": null, "hidden": false, "provider": "pyas", "providerRefs": { "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, { "id": "calfda58c59801w", "name": "Work", "description": null, "timezone": "America/New_York", "isPrimary": false, "color": "#039be5", "accessRole": "owner", "location": null, "conferencing": { "default": null, "allowed": [ "zoom" ] }, "tallyResponses": null, "hidden": false, "provider": "pyas", "providerRefs": { "calendarId": "calfda58c59801w", "accountId": "sbx_fda58c5980_1" }, "metadata": null } ], "provider": "pyas", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": { "nextCursor": null } } ``` :::: ::: :: --- ::section-heading{id="calendars-create" tag="POST" label="/v2/calendars"} Create a calendar :: ::row :::column :try-it{operation="calendars.create"} ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. You can send it here or in the query. ::::: :::::property{name="name" type="string" required} ::::: :::::property{name="description" type="string | null"} ::::: :::::property{name="timezone" type="string | null"} ::::: :::::property{name="color" type="string | null"} The color, as `#rrggbb`. ::::: :::::property{name="location" type="string | null"} Google Calendar only. ::::: :::::property{name="conferencing" type="object"} ::::: :::::property{name="conferencing.default" type="string | null"} ::::: :::::property{name="conferencing.allowed" type="array of string"} ::::: :::::property{name="tallyResponses" type="boolean"} Outlook only. ::::: :::::property{name="hidden" type="boolean"} Google Calendar only. Whether to hide the calendar from the calendar list. ::::: :::::property{name="groupId" type="string"} Outlook only, when creating a calendar. The calendar group to create it in. ::::: :::::property{name="metadata" type="Metadata"} Your own key-value pairs, stored by Pyas with the object. A value is text, a number, a boolean or a list of those. ::::: :::::property{name="providerOptions" type="ProviderOptions"} Provider-specific fields that have no equivalent in v2, grouped by provider. Each group applies only to accounts on that provider. ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`VALIDATION_INVALID_TIMEZONE`](/errors/VALIDATION_INVALID_TIMEZONE) (400): The time zone is not an IANA time zone name. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) (422): The user revoked access, or the provider no longer accepts the stored credentials. - [`CONNECTION_TOKEN_EXPIRED`](/errors/CONNECTION_TOKEN_EXPIRED) (422): The provider's access token expired and could not be refreshed. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. - [`PROVIDER_TIMEOUT`](/errors/PROVIDER_TIMEOUT) (504): The calendar provider did not respond in time. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/calendars"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/calendars' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "accountId": "sbx_fda58c5980_1", "name": "Client meetings", "timezone": "America/New_York", "color": "#33b679", "metadata": { "team": "sales" } }' ``` ```js [Node.js] const result = await pyas.calendars.create({ "accountId": "sbx_fda58c5980_1", "name": "Client meetings", "timezone": "America/New_York", "color": "#33b679", "metadata": { "team": "sales" } }) ``` :::: ::::code-group{title="Response" label="201 Created"} ```json { "success": true, "data": { "id": "calba3nh0btrcap9js6fcfv", "name": "Client meetings", "description": null, "timezone": "America/New_York", "isPrimary": false, "color": "#33b679", "accessRole": "owner", "location": null, "conferencing": { "default": null, "allowed": [ "zoom" ] }, "tallyResponses": null, "hidden": false, "provider": "pyas", "providerRefs": { "calendarId": "calba3nh0btrcap9js6fcfv", "accountId": "sbx_fda58c5980_1" }, "metadata": { "team": "sales" } }, "provider": "pyas", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="colors-get" tag="GET" label="/v2/colors"} Get color palettes :: ::row :::column Returns the calendar and event colors that the account's provider offers, as `#rrggbb` values. :try-it{operation="colors.get"} ### Query parameters ::::properties :::::property{name="accountId" type="string" required} The Pyas `accountId` of the connected account. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/colors"} ```bash [cURL] curl 'https://api.pyas.io/v2/colors?accountId=sbx_fda58c5980_google' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.colors.get({ "accountId": "sbx_fda58c5980_google" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "calendarColors": [ { "id": "1", "color": "#ac725e" }, { "id": "2", "color": "#d06b64" }, { "id": "3", "color": "#f83a22" }, { "id": "4", "color": "#fa573c" }, { "id": "5", "color": "#ff7537" }, { "id": "6", "color": "#ffad46" }, { "id": "7", "color": "#42d692" }, { "id": "8", "color": "#16a765" }, { "id": "9", "color": "#7bd148" }, { "id": "10", "color": "#b3dc6c" }, { "id": "11", "color": "#fbe983" }, { "id": "12", "color": "#fad165" }, { "id": "13", "color": "#92e1c0" }, { "id": "14", "color": "#9fe1e7" }, { "id": "15", "color": "#9fc6e7" }, { "id": "16", "color": "#4986e7" }, { "id": "17", "color": "#9a9cff" }, { "id": "18", "color": "#b99aff" }, { "id": "19", "color": "#c2c2c2" }, { "id": "20", "color": "#cabdbf" }, { "id": "21", "color": "#cca6ac" }, { "id": "22", "color": "#f691b2" }, { "id": "23", "color": "#cd74e6" }, { "id": "24", "color": "#a47ae2" } ], "eventColors": [ { "id": "1", "color": "#a4bdfc" }, { "id": "2", "color": "#7ae7bf" }, { "id": "3", "color": "#dbadff" }, { "id": "4", "color": "#ff887c" }, { "id": "5", "color": "#fbd75b" }, { "id": "6", "color": "#ffb878" }, { "id": "7", "color": "#46d6db" }, { "id": "8", "color": "#e1e1e1" }, { "id": "9", "color": "#5484ed" }, { "id": "10", "color": "#51b749" }, { "id": "11", "color": "#dc2127" } ] }, "provider": "google", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="calendargroups-rename" tag="PATCH" label="/v2/calendar-groups/{groupId}"} Rename a calendar group :: ::row :::column Calendar groups are Outlook folders that contain calendars. Google Calendar doesn't have calendar groups, so requests for Google accounts are rejected. :try-it{operation="calendarGroups.rename"} ### Path parameters ::::properties :::::property{name="groupId" type="string" required} ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. You can send it here or in the query. ::::: :::::property{name="name" type="string" required} ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. ::: :::column{sticky} ::::code-group{title="Request" tag="PATCH" label="/v2/calendar-groups/{groupId}"} ```bash [cURL] curl -X PATCH 'https://api.pyas.io/v2/calendar-groups/AAMkAYE1Kc3zrnSLkXx5fXHBEPOfQSybhu8nRccpNiIpXWVwAAA%3D?accountId=sbx_fda58c5980_outlook' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "name": "Key projects" }' ``` ```js [Node.js] const result = await pyas.calendarGroups.rename({ "groupId": "AAMkAYE1Kc3zrnSLkXx5fXHBEPOfQSybhu8nRccpNiIpXWVwAAA=", "accountId": "sbx_fda58c5980_outlook", "name": "Key projects" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "id": "AAMkAYE1Kc3zrnSLkXx5fXHBEPOfQSybhu8nRccpNiIpXWVwAAA=", "name": "Key projects", "calendarIds": [] }, "provider": "microsoft", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="calendargroups-delete" tag="DELETE" label="/v2/calendar-groups/{groupId}"} Delete a calendar group :: ::row :::column Calendar groups are Outlook folders that contain calendars. Google Calendar doesn't have calendar groups, so requests for Google accounts are rejected. **In the sandbox:** Outlook doesn't delete a group that still contains calendars, and the API returns an error explaining why. :try-it{operation="calendarGroups.delete"} ### Path parameters ::::properties :::::property{name="groupId" type="string" required} ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. ::: :::column{sticky} ::::code-group{title="Request" tag="DELETE" label="/v2/calendar-groups/{groupId}"} ```bash [cURL] curl -X DELETE 'https://api.pyas.io/v2/calendar-groups/AAMkAYE1Kc3zrnSLkXx5fXHBEPOfQSybhu8nRccpNiIpXWVwAAA%3D?accountId=sbx_fda58c5980_outlook' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.calendarGroups.delete({ "groupId": "AAMkAYE1Kc3zrnSLkXx5fXHBEPOfQSybhu8nRccpNiIpXWVwAAA=", "accountId": "sbx_fda58c5980_outlook" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": null, "provider": "microsoft", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="calendars-get" tag="GET" label="/v2/calendars/{calendarRef}"} Get a calendar :: ::row :::column :try-it{operation="calendars.get"} ### Path parameters ::::properties :::::property{name="calendarRef" type="string" required} The calendar ID returned by the provider, or `primary` for the account's primary calendar. ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string" required} The `accountId` of the connected account. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) (422): The user revoked access, or the provider no longer accepts the stored credentials. - [`CONNECTION_TOKEN_EXPIRED`](/errors/CONNECTION_TOKEN_EXPIRED) (422): The provider's access token expired and could not be refreshed. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. - [`PROVIDER_TIMEOUT`](/errors/PROVIDER_TIMEOUT) (504): The calendar provider did not respond in time. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/calendars/{calendarRef}"} ```bash [cURL] curl 'https://api.pyas.io/v2/calendars/calfda58c59801w?accountId=sbx_fda58c5980_1' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.calendars.get({ "calendarRef": "calfda58c59801w", "accountId": "sbx_fda58c5980_1" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "id": "calfda58c59801w", "name": "Work", "description": null, "timezone": "America/New_York", "isPrimary": false, "color": "#039be5", "accessRole": "owner", "location": null, "conferencing": { "default": null, "allowed": [ "zoom" ] }, "tallyResponses": null, "hidden": false, "provider": "pyas", "providerRefs": { "calendarId": "calfda58c59801w", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, "provider": "pyas", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="calendars-update" tag="PATCH" label="/v2/calendars/{calendarRef}"} Update a calendar :: ::row :::column Updates the calendar. Send only the fields to update. Set `description`, `timezone`, `color` or `location` to `null` to clear it. :try-it{operation="calendars.update"} ### Path parameters ::::properties :::::property{name="calendarRef" type="string" required} The calendar ID returned by the provider, or `primary` for the account's primary calendar. ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. You can send it here or in the query. ::::: :::::property{name="name" type="string"} ::::: :::::property{name="description" type="string | null"} ::::: :::::property{name="timezone" type="string | null"} ::::: :::::property{name="color" type="string | null"} The color, as `#rrggbb`. ::::: :::::property{name="location" type="string | null"} Google Calendar only. ::::: :::::property{name="conferencing" type="object"} ::::: :::::property{name="conferencing.default" type="string | null"} ::::: :::::property{name="conferencing.allowed" type="array of string"} ::::: :::::property{name="tallyResponses" type="boolean"} Outlook only. ::::: :::::property{name="hidden" type="boolean"} Google Calendar only. Whether to hide the calendar from the calendar list. ::::: :::::property{name="groupId" type="string"} Outlook only, when creating a calendar. The calendar group to create it in. ::::: :::::property{name="metadata" type="Metadata"} Your own key-value pairs, stored by Pyas with the object. A value is text, a number, a boolean or a list of those. ::::: :::::property{name="providerOptions" type="ProviderOptions"} Provider-specific fields that have no equivalent in v2, grouped by provider. Each group applies only to accounts on that provider. ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`VALIDATION_INVALID_TIMEZONE`](/errors/VALIDATION_INVALID_TIMEZONE) (400): The time zone is not an IANA time zone name. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) (422): The user revoked access, or the provider no longer accepts the stored credentials. - [`CONNECTION_TOKEN_EXPIRED`](/errors/CONNECTION_TOKEN_EXPIRED) (422): The provider's access token expired and could not be refreshed. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. - [`PROVIDER_TIMEOUT`](/errors/PROVIDER_TIMEOUT) (504): The calendar provider did not respond in time. ::: :::column{sticky} ::::code-group{title="Request" tag="PATCH" label="/v2/calendars/{calendarRef}"} ```bash [cURL] curl -X PATCH 'https://api.pyas.io/v2/calendars/calfda58c59801w?accountId=sbx_fda58c5980_1' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "name": "Work (team)" }' ``` ```js [Node.js] const result = await pyas.calendars.update({ "calendarRef": "calfda58c59801w", "accountId": "sbx_fda58c5980_1", "name": "Work (team)" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "id": "calfda58c59801w", "name": "Work (team)", "description": null, "timezone": "America/New_York", "isPrimary": false, "color": "#039be5", "accessRole": "owner", "location": null, "conferencing": { "default": null, "allowed": [ "zoom" ] }, "tallyResponses": null, "hidden": false, "provider": "pyas", "providerRefs": { "calendarId": "calfda58c59801w", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, "provider": "pyas", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="calendars-delete" tag="DELETE" label="/v2/calendars/{calendarRef}"} Delete a calendar :: ::row :::column Deletes the calendar and its events at the provider. A primary calendar cannot be deleted. :try-it{operation="calendars.delete"} ### Path parameters ::::properties :::::property{name="calendarRef" type="string" required} The calendar ID returned by the provider, or `primary` for the account's primary calendar. ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string" required} The `accountId` of the connected account. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) (422): The user revoked access, or the provider no longer accepts the stored credentials. - [`CONNECTION_TOKEN_EXPIRED`](/errors/CONNECTION_TOKEN_EXPIRED) (422): The provider's access token expired and could not be refreshed. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. - [`PROVIDER_TIMEOUT`](/errors/PROVIDER_TIMEOUT) (504): The calendar provider did not respond in time. ::: :::column{sticky} ::::code-group{title="Request" tag="DELETE" label="/v2/calendars/{calendarRef}"} ```bash [cURL] curl -X DELETE 'https://api.pyas.io/v2/calendars/calfda58c59804w?accountId=sbx_fda58c5980_4' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.calendars.delete({ "calendarRef": "calfda58c59804w", "accountId": "sbx_fda58c5980_4" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": null, "provider": "pyas", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="calendars-subscribe" tag="POST" label="/v2/calendars/subscribe"} Subscribe to a calendar :: ::row :::column Adds a calendar that the account has access to, such as a colleague's calendar or a shared calendar, to the account's calendar list. **In the sandbox:** Google adds any calendar the account has access to. The sandbox accepts any calendar ID, but a live account must have access to the calendar. Outlook adds a shared calendar only when the person accepts the invitation, so for Outlook accounts the API returns an error explaining that it can't add the calendar. :try-it{operation="calendars.subscribe"} ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. You can send it here or in the query. ::::: :::::property{name="calendarId" type="string" required} The ID of the calendar to add, such as a colleague's email address. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/calendars/subscribe"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/calendars/subscribe' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "accountId": "sbx_fda58c5980_google", "calendarId": "team-calendar@example.com" }' ``` ```js [Node.js] const result = await pyas.calendars.subscribe({ "accountId": "sbx_fda58c5980_google", "calendarId": "team-calendar@example.com" }) ``` :::: ::::code-group{title="Response" label="201 Created"} ```json { "success": true, "data": { "id": "team-calendar@example.com", "name": "team-calendar@example.com", "description": null, "timezone": "America/New_York", "isPrimary": false, "color": "#9fc6e7", "accessRole": "reader", "location": null, "conferencing": { "default": null, "allowed": [] }, "tallyResponses": null, "hidden": false, "provider": "google", "providerRefs": { "calendarId": "team-calendar@example.com", "accountId": "sbx_fda58c5980_google" } }, "provider": "google", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="calendars-categories-list" tag="GET" label="/v2/calendars/{calendarRef}/categories"} List categories :: ::row :::column Lists the values that events accept in `categories`. On Outlook, there is one list for the whole mailbox. On Google Calendar, these are the calendar's event labels. :try-it{operation="calendars.categories.list"} ### Path parameters ::::properties :::::property{name="calendarRef" type="string" required} The calendar ID returned by the provider, or `primary` for the account's primary calendar. ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/calendars/{calendarRef}/categories"} ```bash [cURL] curl 'https://api.pyas.io/v2/calendars/primary/categories?accountId=sbx_fda58c5980_outlook' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.calendars.categories.list({ "calendarRef": "primary", "accountId": "sbx_fda58c5980_outlook" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": [ { "id": "2eb82094-ec40-4d5d-a8b8-15cf273998c1", "name": "Red category", "color": "#e74856" }, { "id": "945326ef-02ee-47a7-a625-8364f33d9abd", "name": "Orange category", "color": "#f7630c" }, { "id": "d4c5c9cb-5502-42de-a335-3f8f85322a98", "name": "Yellow category", "color": "#fce100" }, { "id": "109ef2a3-89fc-4525-a7b8-d4d3eddfec23", "name": "Green category", "color": "#16c60c" }, { "id": "0eed55fa-f6a2-4cb2-a808-1ab178ff93b5", "name": "Blue category", "color": "#0078d7" }, { "id": "18d6d65c-6b1e-47ac-ac6f-5ae45fad5968", "name": "Purple category", "color": "#8764b8" } ], "provider": "microsoft", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="calendars-categories-create" tag="POST" label="/v2/calendars/{calendarRef}/categories"} Create a category :: ::row :::column **In the sandbox:** Outlook categories belong to the mailbox, so they are the same for every calendar, and they use Outlook's own colors (the nearest color is used). Google categories are per-calendar labels and accept any color. :try-it{operation="calendars.categories.create"} ### Path parameters ::::properties :::::property{name="calendarRef" type="string" required} The calendar ID returned by the provider, or `primary` for the account's primary calendar. ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. You can send it here or in the query. ::::: :::::property{name="name" type="string" required} ::::: :::::property{name="color" type="string"} ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`RESOURCE_CONFLICT`](/errors/RESOURCE_CONFLICT) (409): The resource was modified or already exists. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/calendars/{calendarRef}/categories"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/calendars/primary/categories?accountId=sbx_fda58c5980_outlook' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "name": "Client work", "color": "#33b679" }' ``` ```js [Node.js] const result = await pyas.calendars.categories.create({ "calendarRef": "primary", "accountId": "sbx_fda58c5980_outlook", "name": "Client work", "color": "#33b679" }) ``` :::: ::::code-group{title="Response" label="201 Created"} ```json { "success": true, "data": { "id": "ee111d87-0428-40cf-875f-2f0f2b7c5d4a", "name": "Client work", "color": "#16c60c" }, "provider": "microsoft", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="calendars-feed-get" tag="GET" label="/v2/calendars/{calendarRef}/feed"} Get a calendar's feed :: ::row :::column Returns the state and link of a virtual calendar's iCalendar subscription feed, which calendar apps can subscribe to. :try-it{operation="calendars.feed.get"} ### Path parameters ::::properties :::::property{name="calendarRef" type="string" required} The calendar ID returned by the provider, or `primary` for the account's primary calendar. ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the virtual calendar account. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/calendars/{calendarRef}/feed"} ```bash [cURL] curl 'https://api.pyas.io/v2/calendars/primary/feed?accountId=sbx_fda58c5980_2' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.calendars.feed.get({ "calendarRef": "primary", "accountId": "sbx_fda58c5980_2" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "enabled": false, "url": null, "webcalUrl": null, "details": null, "createdAt": null, "expiresAt": null }, "provider": "pyas", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="calendars-feed-enable" tag="PUT" label="/v2/calendars/{calendarRef}/feed"} Enable a calendar's feed :: ::row :::column Publishes a virtual calendar as an iCalendar feed that people can subscribe to. If the feed is already enabled, only `details` is updated, and the link stays the same. :try-it{operation="calendars.feed.enable"} ### Path parameters ::::properties :::::property{name="calendarRef" type="string" required} The calendar ID returned by the provider, or `primary` for the account's primary calendar. ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the virtual calendar account. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="accountId" type="string"} The `accountId` of the virtual calendar account. You can also send it as a query parameter. ::::: :::::property{name="details" type="string"} `full` (the default) includes titles, locations and notes. `busy` shows only when the calendar is busy. One of `full`, `busy`. ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="PUT" label="/v2/calendars/{calendarRef}/feed"} ```bash [cURL] curl -X PUT 'https://api.pyas.io/v2/calendars/primary/feed?accountId=sbx_fda58c5980_2' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "details": "busy" }' ``` ```js [Node.js] const result = await pyas.calendars.feed.enable({ "calendarRef": "primary", "accountId": "sbx_fda58c5980_2", "details": "busy" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "enabled": true, "url": "https://cal.pyas.io/feeds/D_Gflr0xO2HNSXG26-HC1jZIOauvvfJyBo_S7MbS8IA.ics", "webcalUrl": "webcal://cal.pyas.io/feeds/D_Gflr0xO2HNSXG26-HC1jZIOauvvfJyBo_S7MbS8IA.ics", "details": "busy", "createdAt": "2026-11-14T20:00:25.503Z", "expiresAt": "2026-11-21T20:00:25.503Z" }, "provider": "pyas", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="calendars-feed-disable" tag="DELETE" label="/v2/calendars/{calendarRef}/feed"} Disable a calendar's feed :: ::row :::column Disables the feed. The feed link stops working, and subscribers stop receiving updates. :try-it{operation="calendars.feed.disable"} ### Path parameters ::::properties :::::property{name="calendarRef" type="string" required} The calendar ID returned by the provider, or `primary` for the account's primary calendar. ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the virtual calendar account. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="DELETE" label="/v2/calendars/{calendarRef}/feed"} ```bash [cURL] curl -X DELETE 'https://api.pyas.io/v2/calendars/primary/feed?accountId=sbx_fda58c5980_2' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.calendars.feed.disable({ "calendarRef": "primary", "accountId": "sbx_fda58c5980_2" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "enabled": false, "url": null, "webcalUrl": null, "details": null, "createdAt": null, "expiresAt": null }, "provider": "pyas", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="calendars-permissions-list" tag="GET" label="/v2/calendars/{calendarRef}/permissions"} List calendar permissions :: ::row :::column :try-it{operation="calendars.permissions.list"} ### Path parameters ::::properties :::::property{name="calendarRef" type="string" required} The calendar ID returned by the provider, or `primary` for the account's primary calendar. ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/calendars/{calendarRef}/permissions"} ```bash [cURL] curl 'https://api.pyas.io/v2/calendars/primary/permissions?accountId=sbx_fda58c5980_google' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.calendars.permissions.list({ "calendarRef": "primary", "accountId": "sbx_fda58c5980_google" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": [ { "id": "user:avery.brooks@example.com", "grantee": { "type": "user", "email": "avery.brooks@example.com", "domain": null, "name": null }, "role": "owner", "removable": false }, { "id": "user:sam.patel@example.org", "grantee": { "type": "user", "email": "sam.patel@example.org", "domain": null, "name": null }, "role": "read", "removable": true } ], "provider": "google", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="calendars-permissions-create" tag="POST" label="/v2/calendars/{calendarRef}/permissions"} Share a calendar :: ::row :::column **In the sandbox:** Each provider supports its own set of roles. Google supports `freeBusy`, `read`, `write` and `owner`. Outlook also supports `limitedRead`, and has delegate roles instead of `owner`. If a provider doesn't support a role, the request is rejected with an error that explains why. :try-it{operation="calendars.permissions.create"} ### Path parameters ::::properties :::::property{name="calendarRef" type="string" required} The calendar ID returned by the provider, or `primary` for the account's primary calendar. ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. You can send it here or in the query. ::::: :::::property{name="grantee" type="object" required} Who to share the calendar with. ::::: :::::property{name="grantee.type" type="string" required} One of `user`, `group`, `domain`, `public`, `organization`. ::::: :::::property{name="grantee.email" type="string"} The email address. Required for a `user` or `group`. ::::: :::::property{name="grantee.domain" type="string"} The domain, such as `example.com`. Required for a `domain`. ::::: :::::property{name="grantee.name" type="string"} ::::: :::::property{name="role" type="PermissionRole" required} The level of access, from least to most: `freeBusy` (free/busy only), `limitedRead` (titles and locations, Outlook only), `read` (everything except private events), `write` (editing), `delegate` and `delegateWithPrivate` (a delegate, Outlook only), and `owner` (full control, Google Calendar only). One of `freeBusy`, `limitedRead`, `read`, `write`, `delegate`, `delegateWithPrivate`, `owner`. ::::: :::::property{name="notify" type="boolean"} Whether to email the grantee about the new permission. Google Calendar only, because Outlook decides whether to send the email. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/calendars/{calendarRef}/permissions"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/calendars/primary/permissions?accountId=sbx_fda58c5980_google' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "grantee": { "type": "user", "email": "assistant@example.com" }, "role": "read" }' ``` ```js [Node.js] const result = await pyas.calendars.permissions.create({ "calendarRef": "primary", "accountId": "sbx_fda58c5980_google", "grantee": { "type": "user", "email": "assistant@example.com" }, "role": "read" }) ``` :::: ::::code-group{title="Response" label="201 Created"} ```json { "success": true, "data": { "id": "user:assistant@example.com", "grantee": { "type": "user", "email": "assistant@example.com", "domain": null, "name": null }, "role": "read", "removable": true }, "provider": "google", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="calendars-unsubscribe" tag="DELETE" label="/v2/calendars/{calendarRef}/subscription"} Unsubscribe from a calendar :: ::row :::column Removes the calendar from the account's calendar list. The calendar itself isn't deleted. :try-it{operation="calendars.unsubscribe"} ### Path parameters ::::properties :::::property{name="calendarRef" type="string" required} The calendar ID returned by the provider, or `primary` for the account's primary calendar. ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. ::: :::column{sticky} ::::code-group{title="Request" tag="DELETE" label="/v2/calendars/{calendarRef}/subscription"} ```bash [cURL] curl -X DELETE 'https://api.pyas.io/v2/calendars/en.usa%23holiday@group.v.calendar.google.com/subscription?accountId=sbx_fda58c5980_google' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.calendars.unsubscribe({ "calendarRef": "en.usa#holiday@group.v.calendar.google.com", "accountId": "sbx_fda58c5980_google" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": null, "provider": "google", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="calendars-categories-update" tag="PATCH" label="/v2/calendars/{calendarRef}/categories/{categoryId}"} Update a category :: ::row :::column **In the sandbox:** On Outlook, you can update a category's color but not its name. On Google, you can also rename labels. :try-it{operation="calendars.categories.update"} ### Path parameters ::::properties :::::property{name="calendarRef" type="string" required} The calendar ID returned by the provider, or `primary` for the account's primary calendar. ::::: :::::property{name="categoryId" type="string" required} ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. You can send it here or in the query. ::::: :::::property{name="name" type="string"} ::::: :::::property{name="color" type="string"} ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. ::: :::column{sticky} ::::code-group{title="Request" tag="PATCH" label="/v2/calendars/{calendarRef}/categories/{categoryId}"} ```bash [cURL] curl -X PATCH 'https://api.pyas.io/v2/calendars/primary/categories/0eed55fa-f6a2-4cb2-a808-1ab178ff93b5?accountId=sbx_fda58c5980_outlook' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "color": "#f6bf26" }' ``` ```js [Node.js] const result = await pyas.calendars.categories.update({ "calendarRef": "primary", "categoryId": "0eed55fa-f6a2-4cb2-a808-1ab178ff93b5", "accountId": "sbx_fda58c5980_outlook", "color": "#f6bf26" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "id": "0eed55fa-f6a2-4cb2-a808-1ab178ff93b5", "name": "Blue category", "color": "#fce100" }, "provider": "microsoft", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="calendars-categories-delete" tag="DELETE" label="/v2/calendars/{calendarRef}/categories/{categoryId}"} Delete a category :: ::row :::column :try-it{operation="calendars.categories.delete"} ### Path parameters ::::properties :::::property{name="calendarRef" type="string" required} The calendar ID returned by the provider, or `primary` for the account's primary calendar. ::::: :::::property{name="categoryId" type="string" required} ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. ::: :::column{sticky} ::::code-group{title="Request" tag="DELETE" label="/v2/calendars/{calendarRef}/categories/{categoryId}"} ```bash [cURL] curl -X DELETE 'https://api.pyas.io/v2/calendars/primary/categories/d4c5c9cb-5502-42de-a335-3f8f85322a98?accountId=sbx_fda58c5980_outlook' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.calendars.categories.delete({ "calendarRef": "primary", "categoryId": "d4c5c9cb-5502-42de-a335-3f8f85322a98", "accountId": "sbx_fda58c5980_outlook" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": null, "provider": "microsoft", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="calendars-feed-rotate" tag="POST" label="/v2/calendars/{calendarRef}/feed/rotate"} Rotate a feed's link :: ::row :::column Creates a new link for the feed. The old link stops working immediately. Use this when a link was shared too widely. :try-it{operation="calendars.feed.rotate"} ### Path parameters ::::properties :::::property{name="calendarRef" type="string" required} The calendar ID returned by the provider, or `primary` for the account's primary calendar. ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the virtual calendar account. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/calendars/{calendarRef}/feed/rotate"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/calendars/primary/feed/rotate?accountId=sbx_fda58c5980_2' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.calendars.feed.rotate({ "calendarRef": "primary", "accountId": "sbx_fda58c5980_2" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "enabled": true, "url": "https://cal.pyas.io/feeds/lWpiTMfSX4Gl5H8tvoNz966qj6jeZuWGltO0U_1tdYw.ics", "webcalUrl": "webcal://cal.pyas.io/feeds/lWpiTMfSX4Gl5H8tvoNz966qj6jeZuWGltO0U_1tdYw.ics", "details": "busy", "createdAt": "2026-11-14T20:00:25.510Z", "expiresAt": "2026-11-21T20:00:25.510Z" }, "provider": "pyas", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="calendars-permissions-update" tag="PATCH" label="/v2/calendars/{calendarRef}/permissions/{permissionId}"} Update a calendar permission :: ::row :::column **In the sandbox:** Google identifies a share by its grantee (`user:email`), and Outlook uses its own ID. Use an ID from the list. :try-it{operation="calendars.permissions.update"} ### Path parameters ::::properties :::::property{name="calendarRef" type="string" required} The calendar ID returned by the provider, or `primary` for the account's primary calendar. ::::: :::::property{name="permissionId" type="string" required} ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. You can send it here or in the query. ::::: :::::property{name="role" type="PermissionRole" required} The level of access, from least to most: `freeBusy` (free/busy only), `limitedRead` (titles and locations, Outlook only), `read` (everything except private events), `write` (editing), `delegate` and `delegateWithPrivate` (a delegate, Outlook only), and `owner` (full control, Google Calendar only). One of `freeBusy`, `limitedRead`, `read`, `write`, `delegate`, `delegateWithPrivate`, `owner`. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. ::: :::column{sticky} ::::code-group{title="Request" tag="PATCH" label="/v2/calendars/{calendarRef}/permissions/{permissionId}"} ```bash [cURL] curl -X PATCH 'https://api.pyas.io/v2/calendars/primary/permissions/user%3Asam.patel@example.org?accountId=sbx_fda58c5980_google' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "role": "write" }' ``` ```js [Node.js] const result = await pyas.calendars.permissions.update({ "calendarRef": "primary", "permissionId": "user:sam.patel@example.org", "accountId": "sbx_fda58c5980_google", "role": "write" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "id": "user:sam.patel@example.org", "grantee": { "type": "user", "email": "sam.patel@example.org", "domain": null, "name": null }, "role": "write", "removable": true }, "provider": "google", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="calendars-permissions-delete" tag="DELETE" label="/v2/calendars/{calendarRef}/permissions/{permissionId}"} Delete a calendar permission :: ::row :::column **In the sandbox:** Deleting a share that no longer exists also returns `200`, on every provider. :try-it{operation="calendars.permissions.delete"} ### Path parameters ::::properties :::::property{name="calendarRef" type="string" required} The calendar ID returned by the provider, or `primary` for the account's primary calendar. ::::: :::::property{name="permissionId" type="string" required} ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. ::: :::column{sticky} ::::code-group{title="Request" tag="DELETE" label="/v2/calendars/{calendarRef}/permissions/{permissionId}"} ```bash [cURL] curl -X DELETE 'https://api.pyas.io/v2/calendars/primary/permissions/user%3Aassistant@example.com?accountId=sbx_fda58c5980_google' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.calendars.permissions.delete({ "calendarRef": "primary", "permissionId": "user:assistant@example.com", "accountId": "sbx_fda58c5980_google" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": null, "provider": "google", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: # Events ::lead Manage events across Google Calendar, Microsoft Outlook and virtual calendars, including metadata, attachments and conferencing. :: ::note The examples use IDs from a sample sandbox. Your sandbox has the same people, calendars and events with its own IDs, which `GET /v2/sandbox` returns. When you're signed in to the dashboard, **Try it** fills in your IDs and sends the request to your sandbox, never to live data. :: --- ::section-heading{id="events-list" tag="GET" label="/v2/events"} List events :: ::row :::column Lists the events in one calendar of a connected account, within an optional time window and with optional filters. To list several accounts as one timeline in start order, send `accountIds` or `subjectId` with `from` and `to`. To find events by your own metadata across the app, send up to 5 `metadata.=` parameters. A recurring series is returned as one event unless `expand=true`. `sync=true` starts an incremental sync of the window. To get only the changes since then, send the `syncToken` from the last page in a later request. The v1 parameter names (`calendarId`, `timeMin`, `timeMax`, `maxResults`, `nextPageToken` and others) still work. Responses to requests that use them include a `Pyas-Deprecated-Fields` header. :try-it{operation="events.list"} ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::::property{name="calendarRef" type="string"} The calendar to use. Defaults to `primary`. ::::: :::::property{name="from" type="string (date-time)"} The start of the time window. ::::: :::::property{name="to" type="string (date-time)"} The end of the time window. ::::: :::::property{name="expand" type="boolean"} Returns each occurrence of a recurring series as a separate event. Required for `sort=start`. ::::: :::::property{name="limit" type="integer"} The maximum number of results in one page. Defaults to 100. From 1 to 250. ::::: :::::property{name="cursor" type="string"} The `nextCursor` from the previous page. ::::: :::::property{name="syncToken" type="string"} The `syncToken` from the last page of a sync. Returns only the changes since that sync. ::::: :::::property{name="sync" type="boolean"} Starts a sync of the time window. Returns every event in the window, with a `syncToken` on the last page. ::::: :::::property{name="sort" type="string"} The sort order: `start` (requires `expand=true`) or `updated`. ::::: :::::property{name="include" type="string"} Additional fields to include, as a comma-separated list. `raw` is the provider's original payload. `extendedProperties` is the provider's extended properties. `conferencingSettings` is available only when you retrieve one event. ::::: :::::property{name="extendedPropertyIds" type="string"} Outlook only, with `include=extendedProperties`. The IDs of up to 10 extended properties to read, of any type, such as `String {guid} Name orderId`, `IntegerArray {guid} Name counts` or `Integer 0x8012`. ::::: :::::property{name="fields" type="string"} The top-level fields to return, comma-separated, such as `id,title,startsAt,endsAt`. The `id` field is always included. ::::: :::::property{name="duplicates" type="string"} With several accounts, how to handle the same event on more than one calendar. `flag` (the default) marks copies with `duplicateOf`. `collapse` keeps the first and lists the others in `alsoIn`. ::::: :::::property{name="q" type="string"} Returns events whose title, description or location contains this text. ::::: :::::property{name="title" type="string"} Returns events whose title contains this text. ::::: :::::property{name="location" type="string"} Returns events whose location contains this text. ::::: :::::property{name="attendee" type="string"} Returns events with any of these attendees, as a comma-separated list of email addresses. ::::: :::::property{name="organizer" type="string"} Returns events organized by any of these email addresses, comma-separated. ::::: :::::property{name="status" type="string"} Any of `confirmed`, `tentative` and `cancelled`, comma-separated. Defaults to `confirmed,tentative`. ::::: :::::property{name="recurring" type="boolean"} `true` returns only events in a recurring series. `false` returns only single events. ::::: :::::property{name="isAllDay" type="boolean"} ::::: :::::property{name="hasConference" type="boolean"} `true` returns only events with a meeting link. `false` returns only events without one. ::::: :::::property{name="conferenceType" type="string"} The conferencing service, such as `zoom`, `meet` or `teams`. ::::: :::::property{name="hasAttachments" type="boolean"} ::::: :::::property{name="showAs" type="string"} Any of `busy`, `free`, `tentative`, `outOfOffice` and `workingElsewhere`, comma-separated. ::::: :::::property{name="eventType" type="string"} Any of `default`, `focusTime`, `outOfOffice`, `workingLocation`, `birthday` and `fromGmail`, comma-separated. ::::: :::::property{name="category" type="string"} Returns events with any of these categories. ::::: :::::property{name="importance" type="string"} Any of `low`, `normal` and `high`, comma-separated. ::::: :::::property{name="visibility" type="string"} ::::: :::::property{name="updatedSince" type="string (date-time)"} Returns events updated since this time. ::::: :::::property{name="accountIds" type="string"} The Pyas `accountId` values of up to 20 connected accounts. Send a comma-separated list or repeat the parameter. ::::: :::::property{name="subjectId" type="string"} Selects every connected account labeled with this `subjectId`. ::::: :::::property{name="provider" type="string"} Limits `accountIds` or `subjectId` to accounts on this provider. One of `google`, `microsoft`, `pyas`. ::::: :::::property{name="calendarRefs" type="string"} With several accounts, up to 10 calendars to read on each account. Defaults to `primary`. Send a comma-separated list or repeat the parameter. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`RESOURCE_SYNC_TOKEN_EXPIRED`](/errors/RESOURCE_SYNC_TOKEN_EXPIRED) (410): The sync token has expired. - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) (422): The user revoked access, or the provider no longer accepts the stored credentials. - [`CONNECTION_TOKEN_EXPIRED`](/errors/CONNECTION_TOKEN_EXPIRED) (422): The provider's access token expired and could not be refreshed. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. - [`PROVIDER_TIMEOUT`](/errors/PROVIDER_TIMEOUT) (504): The calendar provider did not respond in time. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/events"} ```bash [cURL] curl 'https://api.pyas.io/v2/events?accountId=sbx_fda58c5980_1&from=2026-11-07T00%3A00%3A00Z&to=2026-11-28T00%3A00%3A00Z&expand=true' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.events.list({ "accountId": "sbx_fda58c5980_1", "from": "2026-11-07T00:00:00Z", "to": "2026-11-28T00:00:00Z", "expand": "true" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": [ { "id": "sb559d0de42dffb4f183c156f8_20261102T143000Z", "iCalUID": "sb559d0de42dffb4f183c156f8@cal.pyas.io", "title": "Daily Standup", "description": null, "location": null, "startsAt": "2026-11-08T14:30:00Z", "endsAt": "2026-11-08T14:45:00Z", "startsAtLocal": "2026-11-08T09:30:00-05:00", "endsAtLocal": "2026-11-08T09:45:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [ { "email": "teammate1.1@example.com", "name": "Morgan", "response": "accepted", "optional": false, "isOrganizer": false }, { "email": "teammate2.1@example.com", "name": "Casey", "response": "tentative", "optional": false, "isOrganizer": false }, { "email": "teammate3.1@example.com", "name": "Drew", "response": "declined", "optional": false, "isOrganizer": false } ], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": { "provider": "meet", "joinUrl": "https://meet.google.com/sbx-4978-a3b1", "meetingId": null, "passcode": null, "dialIn": [] }, "meetingUrl": "https://meet.google.com/sbx-4978-a3b1", "recurrence": null, "series": { "masterEventId": "sb559d0de42dffb4f183c156f8", "originalStartsAt": "2026-11-08T14:30:00Z", "isException": false }, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.554Z", "updatedAt": "2026-11-14T20:00:23.554Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sb559d0de42dffb4f183c156f8_20261102T143000Z", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, { "id": "sba938ba8604d9bdff564147c2_20261102T190000Z", "iCalUID": "sba938ba8604d9bdff564147c2@cal.pyas.io", "title": "1:1 with Sarah", "description": null, "location": null, "startsAt": "2026-11-08T19:00:00Z", "endsAt": "2026-11-08T19:30:00Z", "startsAtLocal": "2026-11-08T14:00:00-05:00", "endsAtLocal": "2026-11-08T14:30:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": { "provider": "zoom", "joinUrl": "https://zoom.us/j/9302336753", "meetingId": null, "passcode": null, "dialIn": [] }, "meetingUrl": "https://zoom.us/j/9302336753", "recurrence": null, "series": { "masterEventId": "sba938ba8604d9bdff564147c2", "originalStartsAt": "2026-11-08T19:00:00Z", "isException": false }, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.578Z", "updatedAt": "2026-11-14T20:00:23.578Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sba938ba8604d9bdff564147c2_20261102T190000Z", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, { "id": "sb559d0de42dffb4f183c156f8_20261103T143000Z", "iCalUID": "sb559d0de42dffb4f183c156f8@cal.pyas.io", "title": "Daily Standup", "description": null, "location": null, "startsAt": "2026-11-09T14:30:00Z", "endsAt": "2026-11-09T14:45:00Z", "startsAtLocal": "2026-11-09T09:30:00-05:00", "endsAtLocal": "2026-11-09T09:45:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [ { "email": "teammate1.1@example.com", "name": "Morgan", "response": "accepted", "optional": false, "isOrganizer": false }, { "email": "teammate2.1@example.com", "name": "Casey", "response": "tentative", "optional": false, "isOrganizer": false }, { "email": "teammate3.1@example.com", "name": "Drew", "response": "declined", "optional": false, "isOrganizer": false } ], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": { "provider": "meet", "joinUrl": "https://meet.google.com/sbx-4978-a3b1", "meetingId": null, "passcode": null, "dialIn": [] }, "meetingUrl": "https://meet.google.com/sbx-4978-a3b1", "recurrence": null, "series": { "masterEventId": "sb559d0de42dffb4f183c156f8", "originalStartsAt": "2026-11-09T14:30:00Z", "isException": false }, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.554Z", "updatedAt": "2026-11-14T20:00:23.554Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sb559d0de42dffb4f183c156f8_20261103T143000Z", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, { "id": "sb559d0de42dffb4f183c156f8_20261104T143000Z", "iCalUID": "sb559d0de42dffb4f183c156f8@cal.pyas.io", "title": "Daily Standup", "description": null, "location": null, "startsAt": "2026-11-10T14:30:00Z", "endsAt": "2026-11-10T14:45:00Z", "startsAtLocal": "2026-11-10T09:30:00-05:00", "endsAtLocal": "2026-11-10T09:45:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [ { "email": "teammate1.1@example.com", "name": "Morgan", "response": "accepted", "optional": false, "isOrganizer": false }, { "email": "teammate2.1@example.com", "name": "Casey", "response": "tentative", "optional": false, "isOrganizer": false }, { "email": "teammate3.1@example.com", "name": "Drew", "response": "declined", "optional": false, "isOrganizer": false } ], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": { "provider": "meet", "joinUrl": "https://meet.google.com/sbx-4978-a3b1", "meetingId": null, "passcode": null, "dialIn": [] }, "meetingUrl": "https://meet.google.com/sbx-4978-a3b1", "recurrence": null, "series": { "masterEventId": "sb559d0de42dffb4f183c156f8", "originalStartsAt": "2026-11-10T14:30:00Z", "isException": false }, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.554Z", "updatedAt": "2026-11-14T20:00:23.554Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sb559d0de42dffb4f183c156f8_20261104T143000Z", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, { "id": "sb559d0de42dffb4f183c156f8_20261105T143000Z", "iCalUID": "sb559d0de42dffb4f183c156f8@cal.pyas.io", "title": "Daily Standup", "description": null, "location": null, "startsAt": "2026-11-11T14:30:00Z", "endsAt": "2026-11-11T14:45:00Z", "startsAtLocal": "2026-11-11T09:30:00-05:00", "endsAtLocal": "2026-11-11T09:45:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [ { "email": "teammate1.1@example.com", "name": "Morgan", "response": "accepted", "optional": false, "isOrganizer": false }, { "email": "teammate2.1@example.com", "name": "Casey", "response": "tentative", "optional": false, "isOrganizer": false }, { "email": "teammate3.1@example.com", "name": "Drew", "response": "declined", "optional": false, "isOrganizer": false } ], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": { "provider": "meet", "joinUrl": "https://meet.google.com/sbx-4978-a3b1", "meetingId": null, "passcode": null, "dialIn": [] }, "meetingUrl": "https://meet.google.com/sbx-4978-a3b1", "recurrence": null, "series": { "masterEventId": "sb559d0de42dffb4f183c156f8", "originalStartsAt": "2026-11-11T14:30:00Z", "isException": false }, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.554Z", "updatedAt": "2026-11-14T20:00:23.554Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sb559d0de42dffb4f183c156f8_20261105T143000Z", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, { "id": "sbf60c8ae6a9f17c1e3b6cc2b8", "iCalUID": "sbf60c8ae6a9f17c1e3b6cc2b8@cal.pyas.io", "title": "Product Sync", "description": null, "location": null, "startsAt": "2026-11-11T16:00:00Z", "endsAt": "2026-11-11T17:00:00Z", "startsAtLocal": "2026-11-11T11:00:00-05:00", "endsAtLocal": "2026-11-11T12:00:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": null, "meetingUrl": null, "recurrence": null, "series": null, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.586Z", "updatedAt": "2026-11-14T20:00:23.586Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sbf60c8ae6a9f17c1e3b6cc2b8", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": { "project": "atlas" } }, { "id": "sb559d0de42dffb4f183c156f8_20261106T143000Z", "iCalUID": "sb559d0de42dffb4f183c156f8@cal.pyas.io", "title": "Daily Standup", "description": null, "location": null, "startsAt": "2026-11-12T14:30:00Z", "endsAt": "2026-11-12T14:45:00Z", "startsAtLocal": "2026-11-12T09:30:00-05:00", "endsAtLocal": "2026-11-12T09:45:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [ { "email": "teammate1.1@example.com", "name": "Morgan", "response": "accepted", "optional": false, "isOrganizer": false }, { "email": "teammate2.1@example.com", "name": "Casey", "response": "tentative", "optional": false, "isOrganizer": false }, { "email": "teammate3.1@example.com", "name": "Drew", "response": "declined", "optional": false, "isOrganizer": false } ], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": { "provider": "meet", "joinUrl": "https://meet.google.com/sbx-4978-a3b1", "meetingId": null, "passcode": null, "dialIn": [] }, "meetingUrl": "https://meet.google.com/sbx-4978-a3b1", "recurrence": null, "series": { "masterEventId": "sb559d0de42dffb4f183c156f8", "originalStartsAt": "2026-11-12T14:30:00Z", "isException": false }, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.554Z", "updatedAt": "2026-11-14T20:00:23.554Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sb559d0de42dffb4f183c156f8_20261106T143000Z", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, { "id": "sba4ff5f08df3d4dcbddca25a3_20261108T150000Z", "iCalUID": "sba4ff5f08df3d4dcbddca25a3@cal.pyas.io", "title": "Sprint Planning", "description": null, "location": null, "startsAt": "2026-11-14T15:00:00Z", "endsAt": "2026-11-14T16:00:00Z", "startsAtLocal": "2026-11-14T10:00:00-05:00", "endsAtLocal": "2026-11-14T11:00:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [ { "email": "teammate1.1@example.com", "name": "Morgan", "response": "accepted", "optional": false, "isOrganizer": false }, { "email": "teammate2.1@example.com", "name": "Casey", "response": "needsAction", "optional": false, "isOrganizer": false }, { "email": "teammate3.1@example.com", "name": "Drew", "response": "declined", "optional": false, "isOrganizer": false } ], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": null, "meetingUrl": null, "recurrence": null, "series": { "masterEventId": "sba4ff5f08df3d4dcbddca25a3", "originalStartsAt": "2026-11-14T15:00:00Z", "isException": false }, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.569Z", "updatedAt": "2026-11-14T20:00:23.569Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sba4ff5f08df3d4dcbddca25a3_20261108T150000Z", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, { "id": "sbce3d2d42c2a4d690a9860fbb", "iCalUID": "sbce3d2d42c2a4d690a9860fbb@cal.pyas.io", "title": "Lunch with Alex", "description": null, "location": "Corner Cafe", "startsAt": "2026-11-14T17:30:00Z", "endsAt": "2026-11-14T18:30:00Z", "startsAtLocal": "2026-11-14T12:30:00-05:00", "endsAtLocal": "2026-11-14T13:30:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": null, "meetingUrl": null, "recurrence": null, "series": null, "reminders": [], "attachments": [], "showAs": "free", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.612Z", "updatedAt": "2026-11-14T20:00:23.612Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sbce3d2d42c2a4d690a9860fbb", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, { "id": "sb559d0de42dffb4f183c156f8_20261109T143000Z", "iCalUID": "sb559d0de42dffb4f183c156f8@cal.pyas.io", "title": "Daily Standup", "description": null, "location": null, "startsAt": "2026-11-15T14:30:00Z", "endsAt": "2026-11-15T14:45:00Z", "startsAtLocal": "2026-11-15T09:30:00-05:00", "endsAtLocal": "2026-11-15T09:45:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [ { "email": "teammate1.1@example.com", "name": "Morgan", "response": "accepted", "optional": false, "isOrganizer": false }, { "email": "teammate2.1@example.com", "name": "Casey", "response": "tentative", "optional": false, "isOrganizer": false }, { "email": "teammate3.1@example.com", "name": "Drew", "response": "declined", "optional": false, "isOrganizer": false } ], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": { "provider": "meet", "joinUrl": "https://meet.google.com/sbx-4978-a3b1", "meetingId": null, "passcode": null, "dialIn": [] }, "meetingUrl": "https://meet.google.com/sbx-4978-a3b1", "recurrence": null, "series": { "masterEventId": "sb559d0de42dffb4f183c156f8", "originalStartsAt": "2026-11-15T14:30:00Z", "isException": false }, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.554Z", "updatedAt": "2026-11-14T20:00:23.554Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sb559d0de42dffb4f183c156f8_20261109T143000Z", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, { "id": "sb16ba49792a79d2ce856e789a", "iCalUID": "sb16ba49792a79d2ce856e789a@cal.pyas.io", "title": "Customer onboarding (Zoom)", "description": null, "location": null, "startsAt": "2026-11-15T16:00:00Z", "endsAt": "2026-11-15T17:00:00Z", "startsAtLocal": "2026-11-15T11:00:00-05:00", "endsAtLocal": "2026-11-15T12:00:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": { "provider": "zoom", "joinUrl": "https://zoom.us/j/67556028668?pwd=k1NV6aHSXvDYLT96s3yhRedsmHRIroO0", "meetingId": "67556028668", "passcode": "1wWGDz58DC", "dialIn": [ { "label": "US New York", "number": "+1 555 0100", "pin": "604224" } ] }, "meetingUrl": "https://zoom.us/j/67556028668?pwd=k1NV6aHSXvDYLT96s3yhRedsmHRIroO0", "recurrence": null, "series": null, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:24.345Z", "updatedAt": "2026-11-14T20:00:24.345Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sb16ba49792a79d2ce856e789a", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, { "id": "sba938ba8604d9bdff564147c2_20261109T190000Z", "iCalUID": "sba938ba8604d9bdff564147c2@cal.pyas.io", "title": "1:1 with Sarah", "description": null, "location": null, "startsAt": "2026-11-15T19:00:00Z", "endsAt": "2026-11-15T19:30:00Z", "startsAtLocal": "2026-11-15T14:00:00-05:00", "endsAtLocal": "2026-11-15T14:30:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": { "provider": "zoom", "joinUrl": "https://zoom.us/j/9302336753", "meetingId": null, "passcode": null, "dialIn": [] }, "meetingUrl": "https://zoom.us/j/9302336753", "recurrence": null, "series": { "masterEventId": "sba938ba8604d9bdff564147c2", "originalStartsAt": "2026-11-15T19:00:00Z", "isException": false }, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.578Z", "updatedAt": "2026-11-14T20:00:23.578Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sba938ba8604d9bdff564147c2_20261109T190000Z", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, { "id": "sbff8ee84b7939a1d7a4943ff6", "iCalUID": "sbff8ee84b7939a1d7a4943ff6@cal.pyas.io", "title": "Design Review", "description": null, "location": "Room 4B", "startsAt": "2026-11-15T20:00:00Z", "endsAt": "2026-11-15T21:00:00Z", "startsAtLocal": "2026-11-15T15:00:00-05:00", "endsAtLocal": "2026-11-15T16:00:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [ { "email": "teammate1.1@example.com", "name": "Morgan", "response": "accepted", "optional": false, "isOrganizer": false }, { "email": "teammate2.1@example.com", "name": "Casey", "response": "needsAction", "optional": false, "isOrganizer": false }, { "email": "teammate3.1@example.com", "name": "Drew", "response": "declined", "optional": false, "isOrganizer": false } ], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": null, "meetingUrl": null, "recurrence": null, "series": null, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.596Z", "updatedAt": "2026-11-14T20:00:23.596Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sbff8ee84b7939a1d7a4943ff6", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, { "id": "sb559d0de42dffb4f183c156f8_20261110T143000Z", "iCalUID": "sb559d0de42dffb4f183c156f8@cal.pyas.io", "title": "Daily Standup", "description": null, "location": null, "startsAt": "2026-11-16T14:30:00Z", "endsAt": "2026-11-16T14:45:00Z", "startsAtLocal": "2026-11-16T09:30:00-05:00", "endsAtLocal": "2026-11-16T09:45:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [ { "email": "teammate1.1@example.com", "name": "Morgan", "response": "accepted", "optional": false, "isOrganizer": false }, { "email": "teammate2.1@example.com", "name": "Casey", "response": "tentative", "optional": false, "isOrganizer": false }, { "email": "teammate3.1@example.com", "name": "Drew", "response": "declined", "optional": false, "isOrganizer": false } ], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": { "provider": "meet", "joinUrl": "https://meet.google.com/sbx-4978-a3b1", "meetingId": null, "passcode": null, "dialIn": [] }, "meetingUrl": "https://meet.google.com/sbx-4978-a3b1", "recurrence": null, "series": { "masterEventId": "sb559d0de42dffb4f183c156f8", "originalStartsAt": "2026-11-16T14:30:00Z", "isException": false }, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.554Z", "updatedAt": "2026-11-14T20:00:23.554Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sb559d0de42dffb4f183c156f8_20261110T143000Z", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, { "id": "sb0c940c95b43e44fcf6650886", "iCalUID": "sb0c940c95b43e44fcf6650886@cal.pyas.io", "title": "Customer Call: Acme", "description": null, "location": null, "startsAt": "2026-11-16T21:00:00Z", "endsAt": "2026-11-16T21:30:00Z", "startsAtLocal": "2026-11-16T16:00:00-05:00", "endsAtLocal": "2026-11-16T16:30:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": { "provider": "zoom", "joinUrl": "https://zoom.us/j/9148612694", "meetingId": null, "passcode": null, "dialIn": [] }, "meetingUrl": "https://zoom.us/j/9148612694", "recurrence": null, "series": null, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.603Z", "updatedAt": "2026-11-14T20:00:23.603Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sb0c940c95b43e44fcf6650886", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": { "dealId": "deal_42", "stage": "discovery" } }, { "id": "sb559d0de42dffb4f183c156f8_20261111T143000Z", "iCalUID": "sb559d0de42dffb4f183c156f8@cal.pyas.io", "title": "Daily Standup", "description": null, "location": null, "startsAt": "2026-11-17T14:30:00Z", "endsAt": "2026-11-17T14:45:00Z", "startsAtLocal": "2026-11-17T09:30:00-05:00", "endsAtLocal": "2026-11-17T09:45:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [ { "email": "teammate1.1@example.com", "name": "Morgan", "response": "accepted", "optional": false, "isOrganizer": false }, { "email": "teammate2.1@example.com", "name": "Casey", "response": "tentative", "optional": false, "isOrganizer": false }, { "email": "teammate3.1@example.com", "name": "Drew", "response": "declined", "optional": false, "isOrganizer": false } ], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": { "provider": "meet", "joinUrl": "https://meet.google.com/sbx-4978-a3b1", "meetingId": null, "passcode": null, "dialIn": [] }, "meetingUrl": "https://meet.google.com/sbx-4978-a3b1", "recurrence": null, "series": { "masterEventId": "sb559d0de42dffb4f183c156f8", "originalStartsAt": "2026-11-17T14:30:00Z", "isException": false }, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.554Z", "updatedAt": "2026-11-14T20:00:23.554Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sb559d0de42dffb4f183c156f8_20261111T143000Z", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, { "id": "sbf36d93862d5bc4f8bca3d967", "iCalUID": "sbf36d93862d5bc4f8bca3d967@cal.pyas.io", "title": "Focus time", "description": null, "location": null, "startsAt": "2026-11-17T18:00:00Z", "endsAt": "2026-11-17T20:00:00Z", "startsAtLocal": "2026-11-17T13:00:00-05:00", "endsAtLocal": "2026-11-17T15:00:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": null, "meetingUrl": null, "recurrence": null, "series": null, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "focusTime", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.627Z", "updatedAt": "2026-11-14T20:00:23.627Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sbf36d93862d5bc4f8bca3d967", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, { "id": "sb559d0de42dffb4f183c156f8_20261112T143000Z", "iCalUID": "sb559d0de42dffb4f183c156f8@cal.pyas.io", "title": "Daily Standup", "description": null, "location": null, "startsAt": "2026-11-18T14:30:00Z", "endsAt": "2026-11-18T14:45:00Z", "startsAtLocal": "2026-11-18T09:30:00-05:00", "endsAtLocal": "2026-11-18T09:45:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [ { "email": "teammate1.1@example.com", "name": "Morgan", "response": "accepted", "optional": false, "isOrganizer": false }, { "email": "teammate2.1@example.com", "name": "Casey", "response": "tentative", "optional": false, "isOrganizer": false }, { "email": "teammate3.1@example.com", "name": "Drew", "response": "declined", "optional": false, "isOrganizer": false } ], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": { "provider": "meet", "joinUrl": "https://meet.google.com/sbx-4978-a3b1", "meetingId": null, "passcode": null, "dialIn": [] }, "meetingUrl": "https://meet.google.com/sbx-4978-a3b1", "recurrence": null, "series": { "masterEventId": "sb559d0de42dffb4f183c156f8", "originalStartsAt": "2026-11-18T14:30:00Z", "isException": false }, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.554Z", "updatedAt": "2026-11-14T20:00:23.554Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sb559d0de42dffb4f183c156f8_20261112T143000Z", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, { "id": "sb03fe318b8d28506fffad9254", "iCalUID": "sb03fe318b8d28506fffad9254@cal.pyas.io", "title": "Dentist", "description": null, "location": null, "startsAt": "2026-11-19T13:00:00Z", "endsAt": "2026-11-19T14:00:00Z", "startsAtLocal": "2026-11-19T08:00:00-05:00", "endsAtLocal": "2026-11-19T09:00:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": "private", "attendees": [], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": null, "meetingUrl": null, "recurrence": null, "series": null, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.619Z", "updatedAt": "2026-11-14T20:00:23.619Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sb03fe318b8d28506fffad9254", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, { "id": "sb559d0de42dffb4f183c156f8_20261113T143000Z", "iCalUID": "sb559d0de42dffb4f183c156f8@cal.pyas.io", "title": "Daily Standup", "description": null, "location": null, "startsAt": "2026-11-19T14:30:00Z", "endsAt": "2026-11-19T14:45:00Z", "startsAtLocal": "2026-11-19T09:30:00-05:00", "endsAtLocal": "2026-11-19T09:45:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [ { "email": "teammate1.1@example.com", "name": "Morgan", "response": "accepted", "optional": false, "isOrganizer": false }, { "email": "teammate2.1@example.com", "name": "Casey", "response": "tentative", "optional": false, "isOrganizer": false }, { "email": "teammate3.1@example.com", "name": "Drew", "response": "declined", "optional": false, "isOrganizer": false } ], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": { "provider": "meet", "joinUrl": "https://meet.google.com/sbx-4978-a3b1", "meetingId": null, "passcode": null, "dialIn": [] }, "meetingUrl": "https://meet.google.com/sbx-4978-a3b1", "recurrence": null, "series": { "masterEventId": "sb559d0de42dffb4f183c156f8", "originalStartsAt": "2026-11-19T14:30:00Z", "isException": false }, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.554Z", "updatedAt": "2026-11-14T20:00:23.554Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sb559d0de42dffb4f183c156f8_20261113T143000Z", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, { "id": "sb559d0de42dffb4f183c156f8_20261116T143000Z", "iCalUID": "sb559d0de42dffb4f183c156f8@cal.pyas.io", "title": "Daily Standup", "description": null, "location": null, "startsAt": "2026-11-22T14:30:00Z", "endsAt": "2026-11-22T14:45:00Z", "startsAtLocal": "2026-11-22T09:30:00-05:00", "endsAtLocal": "2026-11-22T09:45:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [ { "email": "teammate1.1@example.com", "name": "Morgan", "response": "accepted", "optional": false, "isOrganizer": false }, { "email": "teammate2.1@example.com", "name": "Casey", "response": "tentative", "optional": false, "isOrganizer": false }, { "email": "teammate3.1@example.com", "name": "Drew", "response": "declined", "optional": false, "isOrganizer": false } ], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": { "provider": "meet", "joinUrl": "https://meet.google.com/sbx-4978-a3b1", "meetingId": null, "passcode": null, "dialIn": [] }, "meetingUrl": "https://meet.google.com/sbx-4978-a3b1", "recurrence": null, "series": { "masterEventId": "sb559d0de42dffb4f183c156f8", "originalStartsAt": "2026-11-22T14:30:00Z", "isException": false }, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.554Z", "updatedAt": "2026-11-14T20:00:23.554Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sb559d0de42dffb4f183c156f8_20261116T143000Z", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, { "id": "sb3dc0420b772f0cc723098085", "iCalUID": "sb3dc0420b772f0cc723098085@cal.pyas.io", "title": "Conference talk prep", "description": null, "location": null, "startsAt": "2026-11-22T15:00:00Z", "endsAt": "2026-11-22T16:30:00Z", "startsAtLocal": "2026-11-22T10:00:00-05:00", "endsAtLocal": "2026-11-22T11:30:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "tentative", "visibility": null, "attendees": [], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": null, "meetingUrl": null, "recurrence": null, "series": null, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.634Z", "updatedAt": "2026-11-14T20:00:23.634Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sb3dc0420b772f0cc723098085", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, { "id": "sba938ba8604d9bdff564147c2_20261116T190000Z", "iCalUID": "sba938ba8604d9bdff564147c2@cal.pyas.io", "title": "1:1 with Sarah", "description": null, "location": null, "startsAt": "2026-11-22T19:00:00Z", "endsAt": "2026-11-22T19:30:00Z", "startsAtLocal": "2026-11-22T14:00:00-05:00", "endsAtLocal": "2026-11-22T14:30:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": { "provider": "zoom", "joinUrl": "https://zoom.us/j/9302336753", "meetingId": null, "passcode": null, "dialIn": [] }, "meetingUrl": "https://zoom.us/j/9302336753", "recurrence": null, "series": { "masterEventId": "sba938ba8604d9bdff564147c2", "originalStartsAt": "2026-11-22T19:00:00Z", "isException": false }, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.578Z", "updatedAt": "2026-11-14T20:00:23.578Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sba938ba8604d9bdff564147c2_20261116T190000Z", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, { "id": "sb559d0de42dffb4f183c156f8_20261117T143000Z", "iCalUID": "sb559d0de42dffb4f183c156f8@cal.pyas.io", "title": "Daily Standup", "description": null, "location": null, "startsAt": "2026-11-23T14:30:00Z", "endsAt": "2026-11-23T14:45:00Z", "startsAtLocal": "2026-11-23T09:30:00-05:00", "endsAtLocal": "2026-11-23T09:45:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [ { "email": "teammate1.1@example.com", "name": "Morgan", "response": "accepted", "optional": false, "isOrganizer": false }, { "email": "teammate2.1@example.com", "name": "Casey", "response": "tentative", "optional": false, "isOrganizer": false }, { "email": "teammate3.1@example.com", "name": "Drew", "response": "declined", "optional": false, "isOrganizer": false } ], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": { "provider": "meet", "joinUrl": "https://meet.google.com/sbx-4978-a3b1", "meetingId": null, "passcode": null, "dialIn": [] }, "meetingUrl": "https://meet.google.com/sbx-4978-a3b1", "recurrence": null, "series": { "masterEventId": "sb559d0de42dffb4f183c156f8", "originalStartsAt": "2026-11-23T14:30:00Z", "isException": false }, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.554Z", "updatedAt": "2026-11-14T20:00:23.554Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sb559d0de42dffb4f183c156f8_20261117T143000Z", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, { "id": "sb381bb09d8b9512fdc96458a7", "iCalUID": "sb381bb09d8b9512fdc96458a7@cal.pyas.io", "title": "Company Offsite", "description": null, "location": "Lake House", "startsAt": "2026-11-24T05:00:00Z", "endsAt": "2026-11-26T05:00:00Z", "startsAtLocal": "2026-11-24T00:00:00-05:00", "endsAtLocal": "2026-11-26T00:00:00-05:00", "timezone": "America/New_York", "isAllDay": true, "status": "confirmed", "visibility": null, "attendees": [], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": null, "meetingUrl": null, "recurrence": null, "series": null, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.643Z", "updatedAt": "2026-11-14T20:00:23.643Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sb381bb09d8b9512fdc96458a7", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, { "id": "sb559d0de42dffb4f183c156f8_20261118T143000Z", "iCalUID": "sb559d0de42dffb4f183c156f8@cal.pyas.io", "title": "Daily Standup", "description": null, "location": null, "startsAt": "2026-11-24T14:30:00Z", "endsAt": "2026-11-24T14:45:00Z", "startsAtLocal": "2026-11-24T09:30:00-05:00", "endsAtLocal": "2026-11-24T09:45:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [ { "email": "teammate1.1@example.com", "name": "Morgan", "response": "accepted", "optional": false, "isOrganizer": false }, { "email": "teammate2.1@example.com", "name": "Casey", "response": "tentative", "optional": false, "isOrganizer": false }, { "email": "teammate3.1@example.com", "name": "Drew", "response": "declined", "optional": false, "isOrganizer": false } ], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": { "provider": "meet", "joinUrl": "https://meet.google.com/sbx-4978-a3b1", "meetingId": null, "passcode": null, "dialIn": [] }, "meetingUrl": "https://meet.google.com/sbx-4978-a3b1", "recurrence": null, "series": { "masterEventId": "sb559d0de42dffb4f183c156f8", "originalStartsAt": "2026-11-24T14:30:00Z", "isException": false }, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.554Z", "updatedAt": "2026-11-14T20:00:23.554Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sb559d0de42dffb4f183c156f8_20261118T143000Z", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, { "id": "sb559d0de42dffb4f183c156f8_20261119T143000Z", "iCalUID": "sb559d0de42dffb4f183c156f8@cal.pyas.io", "title": "Daily Standup", "description": null, "location": null, "startsAt": "2026-11-25T14:30:00Z", "endsAt": "2026-11-25T14:45:00Z", "startsAtLocal": "2026-11-25T09:30:00-05:00", "endsAtLocal": "2026-11-25T09:45:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [ { "email": "teammate1.1@example.com", "name": "Morgan", "response": "accepted", "optional": false, "isOrganizer": false }, { "email": "teammate2.1@example.com", "name": "Casey", "response": "tentative", "optional": false, "isOrganizer": false }, { "email": "teammate3.1@example.com", "name": "Drew", "response": "declined", "optional": false, "isOrganizer": false } ], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": { "provider": "meet", "joinUrl": "https://meet.google.com/sbx-4978-a3b1", "meetingId": null, "passcode": null, "dialIn": [] }, "meetingUrl": "https://meet.google.com/sbx-4978-a3b1", "recurrence": null, "series": { "masterEventId": "sb559d0de42dffb4f183c156f8", "originalStartsAt": "2026-11-25T14:30:00Z", "isException": false }, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.554Z", "updatedAt": "2026-11-14T20:00:23.554Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sb559d0de42dffb4f183c156f8_20261119T143000Z", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, { "id": "sb559d0de42dffb4f183c156f8_20261120T143000Z", "iCalUID": "sb559d0de42dffb4f183c156f8@cal.pyas.io", "title": "Daily Standup", "description": null, "location": null, "startsAt": "2026-11-26T14:30:00Z", "endsAt": "2026-11-26T14:45:00Z", "startsAtLocal": "2026-11-26T09:30:00-05:00", "endsAtLocal": "2026-11-26T09:45:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [ { "email": "teammate1.1@example.com", "name": "Morgan", "response": "accepted", "optional": false, "isOrganizer": false }, { "email": "teammate2.1@example.com", "name": "Casey", "response": "tentative", "optional": false, "isOrganizer": false }, { "email": "teammate3.1@example.com", "name": "Drew", "response": "declined", "optional": false, "isOrganizer": false } ], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": { "provider": "meet", "joinUrl": "https://meet.google.com/sbx-4978-a3b1", "meetingId": null, "passcode": null, "dialIn": [] }, "meetingUrl": "https://meet.google.com/sbx-4978-a3b1", "recurrence": null, "series": { "masterEventId": "sb559d0de42dffb4f183c156f8", "originalStartsAt": "2026-11-26T14:30:00Z", "isException": false }, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.554Z", "updatedAt": "2026-11-14T20:00:23.554Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sb559d0de42dffb4f183c156f8_20261120T143000Z", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null } ], "provider": "pyas", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": { "nextCursor": null, "syncToken": null } } ``` :::: ::: :: --- ::section-heading{id="events-create" tag="POST" label="/v2/events"} Create an event :: ::row :::column Creates an event on a Google Calendar, Microsoft Outlook or virtual calendar. The event can include attendees, a recurrence rule, a meeting link (Google Meet, Microsoft Teams or Zoom), rooms and your own metadata. The v1 request body (`{ accountId, calendarId, eventData }`) is also accepted. **In the sandbox:** `conferencing` creates the online meeting. Use `meet` on Google, `teams` on Outlook, or `zoom` with a Zoom account on any calendar ({ "provider": "zoom", "accountId": "sbx_fda58c5980_zoom" }). :try-it{operation="events.create"} ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::::property{name="calendarRef" type="string"} The calendar to use. Defaults to `primary`. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and doesn't create the event twice. ::::: :::: ### Body ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. You can send it here or in the query. ::::: :::::property{name="calendarRef" type="string"} The calendar to use. Defaults to `primary`. You can send it here or in the query. ::::: :::::property{name="title" type="string" required} ::::: :::::property{name="description" type="string"} ::::: :::::property{name="location" type="string"} ::::: :::::property{name="startsAt" type="string" required} An ISO 8601 time with a zone, or a date such as `2026-03-15` for an all-day event. ::::: :::::property{name="endsAt" type="string" required} The end of the event. Must be later than `startsAt`. ::::: :::::property{name="timezone" type="string"} The IANA time zone of the event. Defaults to the calendar's time zone. ::::: :::::property{name="isAllDay" type="boolean"} ::::: :::::property{name="attendees" type="array of object"} At most 500 items. ::::: :::::property{name="rooms" type="array of string"} The email addresses of the rooms to book (see `GET /v2/rooms`). On an update, send the complete list. At most 20 items. ::::: :::::property{name="recurrence" type="string"} An RFC 5545 RRULE, such as `FREQ=WEEKLY;BYDAY=MO`. ::::: :::::property{name="conferencing" type="object"} A meeting link, created with the event. ::::: :::::property{name="conferencing.provider" type="string" required} One of `meet`, `teams`, `zoom`. ::::: :::::property{name="conferencing.accountId" type="string"} For `zoom`, the connected Zoom account to create the meeting with. ::::: :::::property{name="conferencing.options" type="ConferencingOptions"} Meeting settings, with the same field names for Zoom, Microsoft Teams and Google Meet. If the conferencing service doesn't support a setting, the request is rejected with the reason. ::::: :::::property{name="reminders" type="array of object"} At most 5 items. ::::: :::::property{name="visibility" type="string"} One of `default`, `public`, `private`, `confidential`. ::::: :::::property{name="notifyAttendees" type="boolean"} Whether the provider emails attendees about this change. Outlook always emails them: there, `false` isn't applied, and the response's `meta.notifyAttendees` says so. ::::: :::::property{name="metadata" type="Metadata"} Your own key-value pairs, stored by Pyas with the object. A value is text, a number, a boolean or a list of those. ::::: :::::property{name="showAs" type="string"} One of `busy`, `free`, `tentative`, `outOfOffice`, `workingElsewhere`. ::::: :::::property{name="color" type="string"} The color, as `#rrggbb`. The provider uses the closest color it offers. ::::: :::::property{name="guests" type="object"} ::::: :::::property{name="guests.canModify" type="boolean"} ::::: :::::property{name="guests.canInviteOthers" type="boolean"} ::::: :::::property{name="guests.canSeeGuests" type="boolean"} ::::: :::::property{name="guests.canProposeTimes" type="boolean"} ::::: :::::property{name="importance" type="string"} One of `low`, `normal`, `high`. ::::: :::::property{name="categories" type="array of string"} At most 25 items. ::::: :::::property{name="responseRequested" type="boolean"} ::::: :::::property{name="autoDecline" type="object"} ::::: :::::property{name="autoDecline.mode" type="string"} One of `none`, `all`, `new`. ::::: :::::property{name="autoDecline.message" type="string | null"} ::::: :::::property{name="doNotDisturb" type="boolean"} ::::: :::::property{name="workingLocation" type="WorkingLocation"} ::::: :::::property{name="source" type="object"} ::::: :::::property{name="source.url" type="string" required} ::::: :::::property{name="source.title" type="string"} ::::: :::::property{name="endTimeUnspecified" type="boolean"} ::::: :::::property{name="privateCopy" type="boolean"} ::::: :::::property{name="status" type="string"} To cancel an event, send a `DELETE` request instead. One of `confirmed`, `tentative`. ::::: :::::property{name="providerOptions" type="ProviderOptions"} Provider-specific fields that have no equivalent in v2, grouped by provider. Each group applies only to accounts on that provider. ::::: :::::property{name="eventType" type="string"} Can be set only when creating an event, because providers don't allow it to be updated. One of `default`, `focusTime`, `outOfOffice`, `workingLocation`, `birthday`. ::::: :::::property{name="id" type="string"} Google Calendar only. Your own event ID, 5 to 1024 characters from a-v and 0-9. ::::: :::::property{name="birthday" type="object"} ::::: :::::property{name="birthday.type" type="string" required} One of `birthday`, `anniversary`, `custom`, `other`, `self`. ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`VALIDATION_INVALID_TIMEZONE`](/errors/VALIDATION_INVALID_TIMEZONE) (400): The time zone is not an IANA time zone name. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`VALIDATION_REJECTED_BY_PROVIDER`](/errors/VALIDATION_REJECTED_BY_PROVIDER) (422): The calendar provider rejected this input. - [`VALIDATION_IDEMPOTENCY_CONFLICT`](/errors/VALIDATION_IDEMPOTENCY_CONFLICT) (422): This idempotency key was already used with a different request. - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) (422): The user revoked access, or the provider no longer accepts the stored credentials. - [`CONNECTION_TOKEN_EXPIRED`](/errors/CONNECTION_TOKEN_EXPIRED) (422): The provider's access token expired and could not be refreshed. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. - [`PROVIDER_TIMEOUT`](/errors/PROVIDER_TIMEOUT) (504): The calendar provider did not respond in time. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/events"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/events' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "accountId": "sbx_fda58c5980_google", "title": "Project kickoff", "startsAt": "2026-11-16T15:00:00Z", "endsAt": "2026-11-16T16:00:00Z", "attendees": [ { "email": "sam.patel@example.org", "name": "Sam Patel" } ], "reminders": [ { "method": "popup", "minutesBefore": 10 } ], "conferencing": { "provider": "meet", "options": { "admission": "invited" } }, "metadata": { "crmId": "opp_981" } }' ``` ```js [Node.js] const result = await pyas.events.create({ "accountId": "sbx_fda58c5980_google", "title": "Project kickoff", "startsAt": "2026-11-16T15:00:00Z", "endsAt": "2026-11-16T16:00:00Z", "attendees": [ { "email": "sam.patel@example.org", "name": "Sam Patel" } ], "reminders": [ { "method": "popup", "minutesBefore": 10 } ], "conferencing": { "provider": "meet", "options": { "admission": "invited" } }, "metadata": { "crmId": "opp_981" } }) ``` :::: ::::code-group{title="Response" label="201 Created"} ```json { "success": true, "data": { "id": "4904j5d3fo5d3gdr94nctoas1kht4b9r", "iCalUID": "4904j5d3fo5d3gdr94nctoas1kht4b9r@google.com", "title": "Project kickoff", "description": null, "location": null, "startsAt": "2026-11-16T15:00:00Z", "endsAt": "2026-11-16T16:00:00Z", "startsAtLocal": "2026-11-16T10:00:00-05:00", "endsAtLocal": "2026-11-16T11:00:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [ { "email": "sam.patel@example.org", "name": "Sam Patel", "response": "needsAction", "optional": false, "isOrganizer": false } ], "rooms": [], "organizer": { "email": "avery.brooks@example.com", "name": "Avery Brooks" }, "conferencing": { "provider": "meet", "joinUrl": "https://meet.google.com/reo-kkbh-vib", "meetingId": "reo-kkbh-vib", "passcode": null, "dialIn": [ { "label": "+1 555-0100", "number": "+1-555-0100", "pin": "603577298" } ], "settings": { "admission": "invited", "recording": "none", "transcription": false } }, "meetingUrl": "https://meet.google.com/reo-kkbh-vib", "recurrence": null, "series": null, "reminders": [ { "method": "popup", "minutesBefore": 10 } ], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:25.552Z", "updatedAt": "2026-11-14T20:00:25.552Z", "version": "\"1\"", "provider": "google", "providerRefs": { "eventId": "4904j5d3fo5d3gdr94nctoas1kht4b9r", "calendarId": "avery.brooks@example.com", "accountId": "sbx_fda58c5980_google" }, "metadata": { "crmId": "opp_981" } }, "provider": "google", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="events-get" tag="GET" label="/v2/events/{eventId}"} Get an event :: ::row :::column :try-it{operation="events.get"} ### Path parameters ::::properties :::::property{name="eventId" type="string" required} The ID of the event. ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string" required} The `accountId` of the connected account. ::::: :::::property{name="calendarRef" type="string"} The calendar to use. Defaults to `primary`. ::::: :::::property{name="include" type="string"} Additional fields to include, as a comma-separated list. `raw` is the provider's original payload. `extendedProperties` is the provider's extended properties. `conferencingSettings` is available only when you retrieve one event. ::::: :::::property{name="extendedPropertyIds" type="string"} Outlook only, with `include=extendedProperties`. The IDs of up to 10 extended properties to read, of any type, such as `String {guid} Name orderId`, `IntegerArray {guid} Name counts` or `Integer 0x8012`. ::::: :::::property{name="fields" type="string"} The top-level fields to return, comma-separated, such as `id,title,startsAt,endsAt`. The `id` field is always included. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) (422): The user revoked access, or the provider no longer accepts the stored credentials. - [`CONNECTION_TOKEN_EXPIRED`](/errors/CONNECTION_TOKEN_EXPIRED) (422): The provider's access token expired and could not be refreshed. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. - [`PROVIDER_TIMEOUT`](/errors/PROVIDER_TIMEOUT) (504): The calendar provider did not respond in time. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/events/{eventId}"} ```bash [cURL] curl 'https://api.pyas.io/v2/events/sb16ba49792a79d2ce856e789a?accountId=sbx_fda58c5980_1' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.events.get({ "eventId": "sb16ba49792a79d2ce856e789a", "accountId": "sbx_fda58c5980_1" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "id": "sb16ba49792a79d2ce856e789a", "iCalUID": "sb16ba49792a79d2ce856e789a@cal.pyas.io", "title": "Customer onboarding (Zoom)", "description": null, "location": null, "startsAt": "2026-11-15T16:00:00Z", "endsAt": "2026-11-15T17:00:00Z", "startsAtLocal": "2026-11-15T11:00:00-05:00", "endsAtLocal": "2026-11-15T12:00:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": { "provider": "zoom", "joinUrl": "https://zoom.us/j/67556028668?pwd=k1NV6aHSXvDYLT96s3yhRedsmHRIroO0", "meetingId": "67556028668", "passcode": "1wWGDz58DC", "dialIn": [ { "label": "US New York", "number": "+1 555 0100", "pin": "604224" } ] }, "meetingUrl": "https://zoom.us/j/67556028668?pwd=k1NV6aHSXvDYLT96s3yhRedsmHRIroO0", "recurrence": null, "series": null, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:24.345Z", "updatedAt": "2026-11-14T20:00:24.345Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sb16ba49792a79d2ce856e789a", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, "provider": "pyas", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="events-update" tag="PATCH" label="/v2/events/{eventId}"} Update an event :: ::row :::column Updates the event. Send only the fields to update. Setting a nullable field to `null` clears it. To update the event only if it hasn't changed since you read it, send its `version` in the `If-Match` header. The v1 request body is also accepted. :try-it{operation="events.update"} ### Path parameters ::::properties :::::property{name="eventId" type="string" required} The ID of the event. ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::::property{name="calendarRef" type="string"} The calendar to use. Defaults to `primary`. ::::: :::: ### Headers ::::properties :::::property{name="If-Match" type="string"} The event's `version` (or `ETag`). The request succeeds only if the event hasn't changed since that version. A stale version returns `RESOURCE_CONFLICT`. ::::: :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. You can send it here or in the query. ::::: :::::property{name="calendarRef" type="string"} The calendar to use. Defaults to `primary`. You can send it here or in the query. ::::: :::::property{name="title" type="string"} ::::: :::::property{name="description" type="string | null"} ::::: :::::property{name="location" type="string | null"} ::::: :::::property{name="startsAt" type="string"} An ISO 8601 time with a zone, or a date such as `2026-03-15` for an all-day event. ::::: :::::property{name="endsAt" type="string"} The end of the event. Must be later than `startsAt`. ::::: :::::property{name="timezone" type="string"} The IANA time zone of the event. Defaults to the calendar's time zone. ::::: :::::property{name="isAllDay" type="boolean"} ::::: :::::property{name="attendees" type="array of object"} At most 500 items. ::::: :::::property{name="rooms" type="array of string"} The email addresses of the rooms to book (see `GET /v2/rooms`). On an update, send the complete list. At most 20 items. ::::: :::::property{name="recurrence" type="string | null"} ::::: :::::property{name="conferencing" type="object | null"} Send `provider` to create a new meeting, `options` alone to update the existing meeting's settings, or `null` to remove the meeting. ::::: :::::property{name="reminders" type="array of object"} At most 5 items. ::::: :::::property{name="visibility" type="string"} One of `default`, `public`, `private`, `confidential`. ::::: :::::property{name="notifyAttendees" type="boolean"} Whether the provider emails attendees about this change. Outlook always emails them: there, `false` isn't applied, and the response's `meta.notifyAttendees` says so. ::::: :::::property{name="metadata" type="Metadata"} Your own key-value pairs, stored by Pyas with the object. A value is text, a number, a boolean or a list of those. ::::: :::::property{name="showAs" type="string"} One of `busy`, `free`, `tentative`, `outOfOffice`, `workingElsewhere`. ::::: :::::property{name="color" type="string | null"} ::::: :::::property{name="guests" type="object"} ::::: :::::property{name="guests.canModify" type="boolean"} ::::: :::::property{name="guests.canInviteOthers" type="boolean"} ::::: :::::property{name="guests.canSeeGuests" type="boolean"} ::::: :::::property{name="guests.canProposeTimes" type="boolean"} ::::: :::::property{name="importance" type="string"} One of `low`, `normal`, `high`. ::::: :::::property{name="categories" type="array of string"} At most 25 items. ::::: :::::property{name="responseRequested" type="boolean"} ::::: :::::property{name="autoDecline" type="object"} ::::: :::::property{name="autoDecline.mode" type="string"} One of `none`, `all`, `new`. ::::: :::::property{name="autoDecline.message" type="string | null"} ::::: :::::property{name="doNotDisturb" type="boolean"} ::::: :::::property{name="workingLocation" type="WorkingLocation"} ::::: :::::property{name="source" type="object | null"} ::::: :::::property{name="endTimeUnspecified" type="boolean"} ::::: :::::property{name="privateCopy" type="boolean"} ::::: :::::property{name="status" type="string"} To cancel an event, send a `DELETE` request instead. One of `confirmed`, `tentative`. ::::: :::::property{name="providerOptions" type="ProviderOptions"} Provider-specific fields that have no equivalent in v2, grouped by provider. Each group applies only to accounts on that provider. ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`VALIDATION_INVALID_TIMEZONE`](/errors/VALIDATION_INVALID_TIMEZONE) (400): The time zone is not an IANA time zone name. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`RESOURCE_CONFLICT`](/errors/RESOURCE_CONFLICT) (409): The resource was modified or already exists. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`VALIDATION_REJECTED_BY_PROVIDER`](/errors/VALIDATION_REJECTED_BY_PROVIDER) (422): The calendar provider rejected this input. - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) (422): The user revoked access, or the provider no longer accepts the stored credentials. - [`CONNECTION_TOKEN_EXPIRED`](/errors/CONNECTION_TOKEN_EXPIRED) (422): The provider's access token expired and could not be refreshed. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. - [`PROVIDER_TIMEOUT`](/errors/PROVIDER_TIMEOUT) (504): The calendar provider did not respond in time. ::: :::column{sticky} ::::code-group{title="Request" tag="PATCH" label="/v2/events/{eventId}"} ```bash [cURL] curl -X PATCH 'https://api.pyas.io/v2/events/sbf60c8ae6a9f17c1e3b6cc2b8?accountId=sbx_fda58c5980_1' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "title": "Product Sync (moved)", "startsAt": "2026-11-15T17:00:00Z", "endsAt": "2026-11-15T18:00:00Z" }' ``` ```js [Node.js] const result = await pyas.events.update({ "eventId": "sbf60c8ae6a9f17c1e3b6cc2b8", "accountId": "sbx_fda58c5980_1", "title": "Product Sync (moved)", "startsAt": "2026-11-15T17:00:00Z", "endsAt": "2026-11-15T18:00:00Z" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "id": "sbf60c8ae6a9f17c1e3b6cc2b8", "iCalUID": "sbf60c8ae6a9f17c1e3b6cc2b8@cal.pyas.io", "title": "Product Sync (moved)", "description": null, "location": null, "startsAt": "2026-11-15T17:00:00Z", "endsAt": "2026-11-15T18:00:00Z", "startsAtLocal": "2026-11-15T12:00:00-05:00", "endsAtLocal": "2026-11-15T13:00:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": null, "meetingUrl": null, "recurrence": null, "series": null, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:24.751Z", "updatedAt": "2026-11-14T20:00:25.757Z", "version": "\"2\"", "provider": "pyas", "providerRefs": { "eventId": "sbf60c8ae6a9f17c1e3b6cc2b8", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": { "project": "atlas" } }, "provider": "pyas", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="events-delete" tag="DELETE" label="/v2/events/{eventId}"} Delete an event :: ::row :::column Deletes the event and cancels it for attendees. To delete it only if it hasn't changed since you read it, send its `version` in the `If-Match` header. :try-it{operation="events.delete"} ### Path parameters ::::properties :::::property{name="eventId" type="string" required} The ID of the event. ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string" required} The `accountId` of the connected account. ::::: :::::property{name="calendarRef" type="string"} The calendar to use. Defaults to `primary`. ::::: :::::property{name="notifyAttendees" type="boolean"} Whether attendees receive a cancellation notice. ::::: :::::property{name="message" type="string"} A message to include with the cancellation. Outlook only, when the account is the organizer. ::::: :::::property{name="sendUpdates" type="string"} The v1 name for `notifyAttendees`: `all`, `externalOnly` or `none`. ::::: :::::property{name="calendarId" type="string"} The v1 name for `calendarRef`. ::::: :::: ### Headers ::::properties :::::property{name="If-Match" type="string"} The event's `version` (or `ETag`). The request succeeds only if the event hasn't changed since that version. A stale version returns `RESOURCE_CONFLICT`. ::::: :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`RESOURCE_CONFLICT`](/errors/RESOURCE_CONFLICT) (409): The resource was modified or already exists. - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) (422): The user revoked access, or the provider no longer accepts the stored credentials. - [`CONNECTION_TOKEN_EXPIRED`](/errors/CONNECTION_TOKEN_EXPIRED) (422): The provider's access token expired and could not be refreshed. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. - [`PROVIDER_TIMEOUT`](/errors/PROVIDER_TIMEOUT) (504): The calendar provider did not respond in time. ::: :::column{sticky} ::::code-group{title="Request" tag="DELETE" label="/v2/events/{eventId}"} ```bash [cURL] curl -X DELETE 'https://api.pyas.io/v2/events/sb0ee767b6db44fa6f8a796921?accountId=sbx_fda58c5980_1¬ifyAttendees=false' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.events.delete({ "eventId": "sb0ee767b6db44fa6f8a796921", "accountId": "sbx_fda58c5980_1", "notifyAttendees": "false" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": null, "provider": "pyas", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="events-import" tag="POST" label="/v2/events/import"} Import an event :: ::row :::column Imports an event from another calendar system and keeps its iCalendar UID, organizer and sequence number. Available only on Google Calendar. :try-it{operation="events.import"} ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::::property{name="calendarRef" type="string"} The calendar to use. Defaults to `primary`. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and doesn't create the event twice. ::::: :::: ### Body ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. You can send it here or in the query. ::::: :::::property{name="calendarRef" type="string"} The calendar to use. Defaults to `primary`. You can send it here or in the query. ::::: :::::property{name="title" type="string" required} ::::: :::::property{name="description" type="string"} ::::: :::::property{name="location" type="string"} ::::: :::::property{name="startsAt" type="string" required} An ISO 8601 time with a zone, or a date such as `2026-03-15` for an all-day event. ::::: :::::property{name="endsAt" type="string" required} The end of the event. Must be later than `startsAt`. ::::: :::::property{name="timezone" type="string"} The IANA time zone of the event. Defaults to the calendar's time zone. ::::: :::::property{name="isAllDay" type="boolean"} ::::: :::::property{name="attendees" type="array of object"} At most 500 items. ::::: :::::property{name="rooms" type="array of string"} The email addresses of the rooms to book (see `GET /v2/rooms`). On an update, send the complete list. At most 20 items. ::::: :::::property{name="recurrence" type="string"} An RFC 5545 RRULE, such as `FREQ=WEEKLY;BYDAY=MO`. ::::: :::::property{name="reminders" type="array of object"} At most 5 items. ::::: :::::property{name="visibility" type="string"} One of `default`, `public`, `private`, `confidential`. ::::: :::::property{name="notifyAttendees" type="boolean"} Whether the provider emails attendees about this change. Outlook always emails them: there, `false` isn't applied, and the response's `meta.notifyAttendees` says so. ::::: :::::property{name="metadata" type="Metadata"} Your own key-value pairs, stored by Pyas with the object. A value is text, a number, a boolean or a list of those. ::::: :::::property{name="showAs" type="string"} One of `busy`, `free`, `tentative`, `outOfOffice`, `workingElsewhere`. ::::: :::::property{name="color" type="string"} The color, as `#rrggbb`. The provider uses the closest color it offers. ::::: :::::property{name="guests" type="object"} ::::: :::::property{name="guests.canModify" type="boolean"} ::::: :::::property{name="guests.canInviteOthers" type="boolean"} ::::: :::::property{name="guests.canSeeGuests" type="boolean"} ::::: :::::property{name="guests.canProposeTimes" type="boolean"} ::::: :::::property{name="importance" type="string"} One of `low`, `normal`, `high`. ::::: :::::property{name="categories" type="array of string"} At most 25 items. ::::: :::::property{name="responseRequested" type="boolean"} ::::: :::::property{name="autoDecline" type="object"} ::::: :::::property{name="autoDecline.mode" type="string"} One of `none`, `all`, `new`. ::::: :::::property{name="autoDecline.message" type="string | null"} ::::: :::::property{name="doNotDisturb" type="boolean"} ::::: :::::property{name="workingLocation" type="WorkingLocation"} ::::: :::::property{name="source" type="object"} ::::: :::::property{name="source.url" type="string" required} ::::: :::::property{name="source.title" type="string"} ::::: :::::property{name="endTimeUnspecified" type="boolean"} ::::: :::::property{name="privateCopy" type="boolean"} ::::: :::::property{name="status" type="string"} To cancel an event, send a `DELETE` request instead. One of `confirmed`, `tentative`. ::::: :::::property{name="providerOptions" type="ProviderOptions"} Provider-specific fields that have no equivalent in v2, grouped by provider. Each group applies only to accounts on that provider. ::::: :::::property{name="eventType" type="string"} Can be set only when creating an event, because providers don't allow it to be updated. One of `default`, `focusTime`, `outOfOffice`, `workingLocation`, `birthday`. ::::: :::::property{name="id" type="string"} Google Calendar only. Your own event ID, 5 to 1024 characters from a-v and 0-9. ::::: :::::property{name="birthday" type="object"} ::::: :::::property{name="birthday.type" type="string" required} One of `birthday`, `anniversary`, `custom`, `other`, `self`. ::::: :::::property{name="iCalUID" type="string" required} The event's iCalendar UID, which the imported event keeps. ::::: :::::property{name="organizer" type="object"} ::::: :::::property{name="organizer.email" type="string" required} ::::: :::::property{name="organizer.name" type="string"} ::::: :::::property{name="sequence" type="integer"} From 0 to 9007199254740991. ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`RESOURCE_CONFLICT`](/errors/RESOURCE_CONFLICT) (409): The resource was modified or already exists. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) (422): The user revoked access, or the provider no longer accepts the stored credentials. - [`CONNECTION_TOKEN_EXPIRED`](/errors/CONNECTION_TOKEN_EXPIRED) (422): The provider's access token expired and could not be refreshed. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. - [`PROVIDER_TIMEOUT`](/errors/PROVIDER_TIMEOUT) (504): The calendar provider did not respond in time. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/events/import"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/events/import' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "accountId": "sbx_fda58c5980_1", "iCalUID": "planning-2026@other-calendar.example", "title": "Planning (imported)", "startsAt": "2026-11-17T10:00:00Z", "endsAt": "2026-11-17T11:00:00Z" }' ``` ```js [Node.js] const result = await pyas.events.import({ "accountId": "sbx_fda58c5980_1", "iCalUID": "planning-2026@other-calendar.example", "title": "Planning (imported)", "startsAt": "2026-11-17T10:00:00Z", "endsAt": "2026-11-17T11:00:00Z" }) ``` :::: ::::code-group{title="Response" label="201 Created"} ```json { "success": true, "data": { "id": "evt2cece5c6brq8a1b6k3ge", "iCalUID": "planning-2026@other-calendar.example", "title": "Planning (imported)", "description": null, "location": null, "startsAt": "2026-11-17T10:00:00Z", "endsAt": "2026-11-17T11:00:00Z", "startsAtLocal": "2026-11-17T05:00:00-05:00", "endsAtLocal": "2026-11-17T06:00:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": null, "meetingUrl": null, "recurrence": null, "series": null, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:25.565Z", "updatedAt": "2026-11-14T20:00:25.565Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "evt2cece5c6brq8a1b6k3ge", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, "provider": "pyas", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="events-quickadd" tag="POST" label="/v2/events/quick"} Create an event from text :: ::row :::column Creates an event from a short text description, such as "Lunch with Sam Friday 1pm". The API interprets the text the same way on every provider. With `dryRun`, the API returns how it interpreted the text without creating the event. :try-it{operation="events.quickAdd"} ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::::property{name="calendarRef" type="string"} The calendar to use. Defaults to `primary`. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and doesn't create the event twice. ::::: :::: ### Body ::::properties :::::property{name="accountId" type="string"} ::::: :::::property{name="calendarRef" type="string"} ::::: :::::property{name="text" type="string" required} ::::: :::::property{name="timezone" type="string"} The IANA time zone used to interpret the text. Defaults to the calendar's time zone. ::::: :::::property{name="durationMinutes" type="integer"} The length of the event in minutes when the text doesn't include an end time. Defaults to 60. From 1 to 1440. ::::: :::::property{name="dryRun" type="boolean"} ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`VALIDATION_INVALID_TIMEZONE`](/errors/VALIDATION_INVALID_TIMEZONE) (400): The time zone is not an IANA time zone name. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) (422): The user revoked access, or the provider no longer accepts the stored credentials. - [`CONNECTION_TOKEN_EXPIRED`](/errors/CONNECTION_TOKEN_EXPIRED) (422): The provider's access token expired and could not be refreshed. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. - [`PROVIDER_TIMEOUT`](/errors/PROVIDER_TIMEOUT) (504): The calendar provider did not respond in time. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/events/quick"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/events/quick' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "accountId": "sbx_fda58c5980_1", "text": "Lunch with Sam Friday 1pm", "timezone": "America/New_York" }' ``` ```js [Node.js] const result = await pyas.events.quickAdd({ "accountId": "sbx_fda58c5980_1", "text": "Lunch with Sam Friday 1pm", "timezone": "America/New_York" }) ``` :::: ::::code-group{title="Response" label="201 Created"} ```json { "success": true, "data": { "id": "evt0khtemav23djrva990ln", "iCalUID": "evt0khtemav23djrva990ln@cal.pyas.io", "title": "Lunch with Sam", "description": null, "location": null, "startsAt": "2026-11-19T18:00:00Z", "endsAt": "2026-11-19T19:00:00Z", "startsAtLocal": "2026-11-19T13:00:00-05:00", "endsAtLocal": "2026-11-19T14:00:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": null, "meetingUrl": null, "recurrence": null, "series": null, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:25.590Z", "updatedAt": "2026-11-14T20:00:25.590Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "evt0khtemav23djrva990ln", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, "provider": "pyas", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": { "parsed": { "title": "Lunch with Sam", "startsAt": "2026-11-19T18:00:00Z", "endsAt": "2026-11-19T19:00:00Z", "isAllDay": false, "timezone": "America/New_York", "matched": "Friday 1pm" } } } ``` :::: ::: :: --- ::section-heading{id="events-attachments-list" tag="GET" label="/v2/events/{eventId}/attachments"} List an event's attachments :: ::row :::column :try-it{operation="events.attachments.list"} ### Path parameters ::::properties :::::property{name="eventId" type="string" required} The ID of the event. ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::::property{name="calendarRef" type="string"} The calendar to use. Defaults to `primary`. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) (422): The user revoked access, or the provider no longer accepts the stored credentials. - [`CONNECTION_TOKEN_EXPIRED`](/errors/CONNECTION_TOKEN_EXPIRED) (422): The provider's access token expired and could not be refreshed. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. - [`PROVIDER_TIMEOUT`](/errors/PROVIDER_TIMEOUT) (504): The calendar provider did not respond in time. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/events/{eventId}/attachments"} ```bash [cURL] curl 'https://api.pyas.io/v2/events/sbff8ee84b7939a1d7a4943ff6/attachments?accountId=sbx_fda58c5980_1' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.events.attachments.list({ "eventId": "sbff8ee84b7939a1d7a4943ff6", "accountId": "sbx_fda58c5980_1" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": [], "provider": "pyas", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="events-attachments-addlink" tag="POST" label="/v2/events/{eventId}/attachments"} Attach a link :: ::row :::column Attaches a link to a file stored elsewhere. Google Calendar accepts links to Google Drive files. Outlook calendars do not support link attachments, so upload the file with `/attachments/file` instead. :try-it{operation="events.attachments.addLink"} ### Path parameters ::::properties :::::property{name="eventId" type="string" required} The ID of the event. ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::::property{name="calendarRef" type="string"} The calendar to use. Defaults to `primary`. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="accountId" type="string"} ::::: :::::property{name="calendarRef" type="string"} ::::: :::::property{name="url" type="string" required} The `https` URL of the file. ::::: :::::property{name="name" type="string"} ::::: :::::property{name="mimeType" type="string"} ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) (422): The user revoked access, or the provider no longer accepts the stored credentials. - [`CONNECTION_TOKEN_EXPIRED`](/errors/CONNECTION_TOKEN_EXPIRED) (422): The provider's access token expired and could not be refreshed. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. - [`PROVIDER_TIMEOUT`](/errors/PROVIDER_TIMEOUT) (504): The calendar provider did not respond in time. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/events/{eventId}/attachments"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/events/sbff8ee84b7939a1d7a4943ff6/attachments?accountId=sbx_fda58c5980_1' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "url": "https://example.com/agenda.pdf", "name": "Agenda.pdf", "mimeType": "application/pdf" }' ``` ```js [Node.js] const result = await pyas.events.attachments.addLink({ "eventId": "sbff8ee84b7939a1d7a4943ff6", "accountId": "sbx_fda58c5980_1", "url": "https://example.com/agenda.pdf", "name": "Agenda.pdf", "mimeType": "application/pdf" }) ``` :::: ::::code-group{title="Response" label="201 Created"} ```json { "success": true, "data": { "id": "att3j6jb64bbls3hsvo51vg", "name": "Agenda.pdf", "mimeType": "application/pdf", "size": null, "url": "https://example.com/agenda.pdf", "kind": "link" }, "provider": "pyas", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="events-forward" tag="POST" label="/v2/events/{eventId}/forward"} Forward an invitation :: ::row :::column **In the sandbox:** On Google, forwarding invites the recipients, so they are added to the attendees. On Outlook, the event is emailed to the recipients, with your comment if you include one. :try-it{operation="events.forward"} ### Path parameters ::::properties :::::property{name="eventId" type="string" required} The ID of the event. ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::::property{name="calendarRef" type="string"} The calendar to use. Defaults to `primary`. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="accountId" type="string"} ::::: :::::property{name="calendarRef" type="string"} ::::: :::::property{name="recipients" type="array of object" required} At most 100 items. ::::: :::::property{name="comment" type="string"} A message to include with the forwarded invitation. Outlook only. ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) (422): The user revoked access, or the provider no longer accepts the stored credentials. - [`CONNECTION_TOKEN_EXPIRED`](/errors/CONNECTION_TOKEN_EXPIRED) (422): The provider's access token expired and could not be refreshed. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. - [`PROVIDER_TIMEOUT`](/errors/PROVIDER_TIMEOUT) (504): The calendar provider did not respond in time. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/events/{eventId}/forward"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/events/sg314c0fd5415a58a532cd018d/forward?accountId=sbx_fda58c5980_google' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "recipients": [ { "email": "colleague@example.com" } ] }' ``` ```js [Node.js] const result = await pyas.events.forward({ "eventId": "sg314c0fd5415a58a532cd018d", "accountId": "sbx_fda58c5980_google", "recipients": [ { "email": "colleague@example.com" } ] }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": null, "provider": "google", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="events-instances" tag="GET" label="/v2/events/{eventId}/instances"} List occurrences of a recurring event :: ::row :::column Returns each occurrence of a recurring event within a time window as a separate event, with `series` set. :try-it{operation="events.instances"} ### Path parameters ::::properties :::::property{name="eventId" type="string" required} The ID of the event. ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string" required} The `accountId` of the connected account. ::::: :::::property{name="calendarRef" type="string"} The calendar to use. Defaults to `primary`. ::::: :::::property{name="from" type="string (date-time)"} The start of the time window. Required. ::::: :::::property{name="to" type="string (date-time)"} The end of the time window. Required. ::::: :::::property{name="limit" type="integer"} The maximum number of results in one page. Defaults to 100. From 1 to 250. ::::: :::::property{name="cursor" type="string"} The `nextCursor` from the previous page. ::::: :::::property{name="include" type="string"} Additional fields to include, as a comma-separated list. `raw` is the provider's original payload. `extendedProperties` is the provider's extended properties. `conferencingSettings` is available only when you retrieve one event. ::::: :::::property{name="extendedPropertyIds" type="string"} ::::: :::::property{name="fields" type="string"} The top-level fields to return, comma-separated, such as `id,title,startsAt,endsAt`. The `id` field is always included. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) (422): The user revoked access, or the provider no longer accepts the stored credentials. - [`CONNECTION_TOKEN_EXPIRED`](/errors/CONNECTION_TOKEN_EXPIRED) (422): The provider's access token expired and could not be refreshed. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. - [`PROVIDER_TIMEOUT`](/errors/PROVIDER_TIMEOUT) (504): The calendar provider did not respond in time. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/events/{eventId}/instances"} ```bash [cURL] curl 'https://api.pyas.io/v2/events/sb559d0de42dffb4f183c156f8/instances?accountId=sbx_fda58c5980_1&from=2026-11-14T00%3A00%3A00Z&to=2026-11-21T00%3A00%3A00Z' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.events.instances({ "eventId": "sb559d0de42dffb4f183c156f8", "accountId": "sbx_fda58c5980_1", "from": "2026-11-14T00:00:00Z", "to": "2026-11-21T00:00:00Z" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": [ { "id": "sb559d0de42dffb4f183c156f8_20261109T143000Z", "iCalUID": "sb559d0de42dffb4f183c156f8@cal.pyas.io", "title": "Daily Standup", "description": null, "location": null, "startsAt": "2026-11-15T14:30:00Z", "endsAt": "2026-11-15T14:45:00Z", "startsAtLocal": "2026-11-15T09:30:00-05:00", "endsAtLocal": "2026-11-15T09:45:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [ { "email": "teammate1.1@example.com", "name": "Morgan", "response": "accepted", "optional": false, "isOrganizer": false }, { "email": "teammate2.1@example.com", "name": "Casey", "response": "tentative", "optional": false, "isOrganizer": false }, { "email": "teammate3.1@example.com", "name": "Drew", "response": "declined", "optional": false, "isOrganizer": false } ], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": { "provider": "meet", "joinUrl": "https://meet.google.com/sbx-4978-a3b1", "meetingId": null, "passcode": null, "dialIn": [] }, "meetingUrl": "https://meet.google.com/sbx-4978-a3b1", "recurrence": null, "series": { "masterEventId": "sb559d0de42dffb4f183c156f8", "originalStartsAt": "2026-11-15T14:30:00Z", "isException": false }, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.554Z", "updatedAt": "2026-11-14T20:00:23.554Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sb559d0de42dffb4f183c156f8_20261109T143000Z", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, { "id": "sb559d0de42dffb4f183c156f8_20261110T143000Z", "iCalUID": "sb559d0de42dffb4f183c156f8@cal.pyas.io", "title": "Daily Standup", "description": null, "location": null, "startsAt": "2026-11-16T14:30:00Z", "endsAt": "2026-11-16T14:45:00Z", "startsAtLocal": "2026-11-16T09:30:00-05:00", "endsAtLocal": "2026-11-16T09:45:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [ { "email": "teammate1.1@example.com", "name": "Morgan", "response": "accepted", "optional": false, "isOrganizer": false }, { "email": "teammate2.1@example.com", "name": "Casey", "response": "tentative", "optional": false, "isOrganizer": false }, { "email": "teammate3.1@example.com", "name": "Drew", "response": "declined", "optional": false, "isOrganizer": false } ], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": { "provider": "meet", "joinUrl": "https://meet.google.com/sbx-4978-a3b1", "meetingId": null, "passcode": null, "dialIn": [] }, "meetingUrl": "https://meet.google.com/sbx-4978-a3b1", "recurrence": null, "series": { "masterEventId": "sb559d0de42dffb4f183c156f8", "originalStartsAt": "2026-11-16T14:30:00Z", "isException": false }, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.554Z", "updatedAt": "2026-11-14T20:00:23.554Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sb559d0de42dffb4f183c156f8_20261110T143000Z", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, { "id": "sb559d0de42dffb4f183c156f8_20261111T143000Z", "iCalUID": "sb559d0de42dffb4f183c156f8@cal.pyas.io", "title": "Daily Standup", "description": null, "location": null, "startsAt": "2026-11-17T14:30:00Z", "endsAt": "2026-11-17T14:45:00Z", "startsAtLocal": "2026-11-17T09:30:00-05:00", "endsAtLocal": "2026-11-17T09:45:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [ { "email": "teammate1.1@example.com", "name": "Morgan", "response": "accepted", "optional": false, "isOrganizer": false }, { "email": "teammate2.1@example.com", "name": "Casey", "response": "tentative", "optional": false, "isOrganizer": false }, { "email": "teammate3.1@example.com", "name": "Drew", "response": "declined", "optional": false, "isOrganizer": false } ], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": { "provider": "meet", "joinUrl": "https://meet.google.com/sbx-4978-a3b1", "meetingId": null, "passcode": null, "dialIn": [] }, "meetingUrl": "https://meet.google.com/sbx-4978-a3b1", "recurrence": null, "series": { "masterEventId": "sb559d0de42dffb4f183c156f8", "originalStartsAt": "2026-11-17T14:30:00Z", "isException": false }, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.554Z", "updatedAt": "2026-11-14T20:00:23.554Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sb559d0de42dffb4f183c156f8_20261111T143000Z", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, { "id": "sb559d0de42dffb4f183c156f8_20261112T143000Z", "iCalUID": "sb559d0de42dffb4f183c156f8@cal.pyas.io", "title": "Daily Standup", "description": null, "location": null, "startsAt": "2026-11-18T14:30:00Z", "endsAt": "2026-11-18T14:45:00Z", "startsAtLocal": "2026-11-18T09:30:00-05:00", "endsAtLocal": "2026-11-18T09:45:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [ { "email": "teammate1.1@example.com", "name": "Morgan", "response": "accepted", "optional": false, "isOrganizer": false }, { "email": "teammate2.1@example.com", "name": "Casey", "response": "tentative", "optional": false, "isOrganizer": false }, { "email": "teammate3.1@example.com", "name": "Drew", "response": "declined", "optional": false, "isOrganizer": false } ], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": { "provider": "meet", "joinUrl": "https://meet.google.com/sbx-4978-a3b1", "meetingId": null, "passcode": null, "dialIn": [] }, "meetingUrl": "https://meet.google.com/sbx-4978-a3b1", "recurrence": null, "series": { "masterEventId": "sb559d0de42dffb4f183c156f8", "originalStartsAt": "2026-11-18T14:30:00Z", "isException": false }, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.554Z", "updatedAt": "2026-11-14T20:00:23.554Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sb559d0de42dffb4f183c156f8_20261112T143000Z", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, { "id": "sb559d0de42dffb4f183c156f8_20261113T143000Z", "iCalUID": "sb559d0de42dffb4f183c156f8@cal.pyas.io", "title": "Daily Standup", "description": null, "location": null, "startsAt": "2026-11-19T14:30:00Z", "endsAt": "2026-11-19T14:45:00Z", "startsAtLocal": "2026-11-19T09:30:00-05:00", "endsAtLocal": "2026-11-19T09:45:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [ { "email": "teammate1.1@example.com", "name": "Morgan", "response": "accepted", "optional": false, "isOrganizer": false }, { "email": "teammate2.1@example.com", "name": "Casey", "response": "tentative", "optional": false, "isOrganizer": false }, { "email": "teammate3.1@example.com", "name": "Drew", "response": "declined", "optional": false, "isOrganizer": false } ], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": { "provider": "meet", "joinUrl": "https://meet.google.com/sbx-4978-a3b1", "meetingId": null, "passcode": null, "dialIn": [] }, "meetingUrl": "https://meet.google.com/sbx-4978-a3b1", "recurrence": null, "series": { "masterEventId": "sb559d0de42dffb4f183c156f8", "originalStartsAt": "2026-11-19T14:30:00Z", "isException": false }, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:23.554Z", "updatedAt": "2026-11-14T20:00:23.554Z", "version": "\"1\"", "provider": "pyas", "providerRefs": { "eventId": "sb559d0de42dffb4f183c156f8_20261113T143000Z", "calendarId": "calfda58c59801p", "accountId": "sbx_fda58c5980_1" }, "metadata": null } ], "provider": "pyas", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": { "nextCursor": null } } ``` :::: ::: :: --- ::section-heading{id="events-move" tag="POST" label="/v2/events/{eventId}/move"} Move an event to another calendar :: ::row :::column Moves an event to another calendar. Outlook has no move operation, so on Outlook the event is recreated with a new ID, and the response includes `meta.recreated` and `meta.previousId`. To move an Outlook event that has attendees, send `recreate: true`. :try-it{operation="events.move"} ### Path parameters ::::properties :::::property{name="eventId" type="string" required} The ID of the event. ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::::property{name="calendarRef" type="string"} The calendar to use. Defaults to `primary`. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="accountId" type="string"} ::::: :::::property{name="calendarRef" type="string"} ::::: :::::property{name="destinationCalendarRef" type="string" required} ::::: :::::property{name="notifyAttendees" type="boolean"} ::::: :::::property{name="recreate" type="boolean"} Outlook only. Confirms that the event can be recreated. Attendees receive a cancellation and a new invitation. ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) (422): The user revoked access, or the provider no longer accepts the stored credentials. - [`CONNECTION_TOKEN_EXPIRED`](/errors/CONNECTION_TOKEN_EXPIRED) (422): The provider's access token expired and could not be refreshed. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. - [`PROVIDER_TIMEOUT`](/errors/PROVIDER_TIMEOUT) (504): The calendar provider did not respond in time. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/events/{eventId}/move"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/events/sbce3d2d42c2a4d690a9860fbb/move?accountId=sbx_fda58c5980_1' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "destinationCalendarRef": "calfda58c59801w" }' ``` ```js [Node.js] const result = await pyas.events.move({ "eventId": "sbce3d2d42c2a4d690a9860fbb", "accountId": "sbx_fda58c5980_1", "destinationCalendarRef": "calfda58c59801w" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "id": "sbce3d2d42c2a4d690a9860fbb", "iCalUID": "sbce3d2d42c2a4d690a9860fbb@cal.pyas.io", "title": "Lunch with Alex", "description": null, "location": "Corner Cafe", "startsAt": "2026-11-14T17:30:00Z", "endsAt": "2026-11-14T18:30:00Z", "startsAtLocal": "2026-11-14T12:30:00-05:00", "endsAtLocal": "2026-11-14T13:30:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [], "rooms": [], "organizer": { "email": "calfda58c59801p@cal.pyas.io", "name": "Avery Brooks" }, "conferencing": null, "meetingUrl": null, "recurrence": null, "series": null, "reminders": [], "attachments": [], "showAs": "free", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:24.771Z", "updatedAt": "2026-11-14T20:00:25.616Z", "version": "\"2\"", "provider": "pyas", "providerRefs": { "eventId": "sbce3d2d42c2a4d690a9860fbb", "calendarId": "calfda58c59801w", "accountId": "sbx_fda58c5980_1" }, "metadata": null }, "provider": "pyas", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": { "recreated": false } } ``` :::: ::: :: --- ::section-heading{id="events-respond" tag="POST" label="/v2/events/{eventId}/respond"} Respond to an invitation :: ::row :::column Sets this account's response to an invitation: `accepted`, `declined` or `tentative`. You can include a comment and, on Outlook, propose a different time. **In the sandbox:** Outlook accepts a proposed time with `tentative` or `declined`. On Google, the response is recorded without a proposed time. The organizer can't respond to their own event. :try-it{operation="events.respond"} ### Path parameters ::::properties :::::property{name="eventId" type="string" required} The ID of the event. ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::::property{name="calendarRef" type="string"} The calendar to use. Defaults to `primary`. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="accountId" type="string"} ::::: :::::property{name="calendarRef" type="string"} ::::: :::::property{name="response" type="string" required} One of `accepted`, `declined`, `tentative`. ::::: :::::property{name="comment" type="string"} ::::: :::::property{name="proposedTime" type="object"} A different time to propose. Outlook only, with `declined` or `tentative`. ::::: :::::property{name="proposedTime.startsAt" type="string" required} ::::: :::::property{name="proposedTime.endsAt" type="string" required} ::::: :::::property{name="notifyOrganizer" type="boolean"} Whether to notify the organizer. Defaults to `true`. ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) (422): The user revoked access, or the provider no longer accepts the stored credentials. - [`CONNECTION_TOKEN_EXPIRED`](/errors/CONNECTION_TOKEN_EXPIRED) (422): The provider's access token expired and could not be refreshed. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. - [`PROVIDER_TIMEOUT`](/errors/PROVIDER_TIMEOUT) (504): The calendar provider did not respond in time. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/events/{eventId}/respond"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/events/AAMkAaqP10JDPp4BdGjaZyg2dF63m6W8eIe6XhxRsmKdLG38AAA%3D/respond?accountId=sbx_fda58c5980_outlook' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "response": "tentative", "comment": "Could we do later in the day?", "proposedTime": { "startsAt": "2026-11-16T16:00:00Z", "endsAt": "2026-11-16T17:00:00Z" } }' ``` ```js [Node.js] const result = await pyas.events.respond({ "eventId": "AAMkAaqP10JDPp4BdGjaZyg2dF63m6W8eIe6XhxRsmKdLG38AAA=", "accountId": "sbx_fda58c5980_outlook", "response": "tentative", "comment": "Could we do later in the day?", "proposedTime": { "startsAt": "2026-11-16T16:00:00Z", "endsAt": "2026-11-16T17:00:00Z" } }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "id": "AAMkAaqP10JDPp4BdGjaZyg2dF63m6W8eIe6XhxRsmKdLG38AAA=", "iCalUID": "040000008200E00074C5B7101A82E008692EB216F94CCB328CF8D509CF2E22779026B52F5F94CC7ACE760D0B", "title": "Vendor demo", "description": null, "location": null, "startsAt": "2026-11-16T15:00:00Z", "endsAt": "2026-11-16T16:00:00Z", "startsAtLocal": "2026-11-16T15:00:00+00:00", "endsAtLocal": "2026-11-16T16:00:00+00:00", "timezone": "Europe/London", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [ { "email": "sam.patel@example.org", "name": "Sam Patel", "response": "tentative", "optional": false, "isOrganizer": false }, { "email": "jordan.lee@example.org", "name": "Jordan Lee", "response": "tentative", "optional": false, "isOrganizer": false } ], "rooms": [], "organizer": { "email": "riley.chen@example.org", "name": "Riley Chen" }, "conferencing": { "provider": "teams", "joinUrl": "https://teams.microsoft.com/l/meetup-join/19%3ameeting_9PnbW93DKzErExDGHH4VYkdBB4uPoz6z%40thread.v2/0", "meetingId": "354 292 634 861", "passcode": "RMXPxx", "dialIn": [ { "label": "Toll", "number": "+1 555-0101", "pin": "769655823" } ] }, "meetingUrl": "https://teams.microsoft.com/l/meetup-join/19%3ameeting_9PnbW93DKzErExDGHH4VYkdBB4uPoz6z%40thread.v2/0", "recurrence": null, "series": null, "reminders": [], "attachments": [], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": true, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:25.447Z", "updatedAt": "2026-11-14T20:00:25.604Z", "version": "\"2\"", "provider": "microsoft", "providerRefs": { "eventId": "AAMkAaqP10JDPp4BdGjaZyg2dF63m6W8eIe6XhxRsmKdLG38AAA=", "calendarId": "AAMkAU662pGNAu0JgQ5Fpjf1wipv7j82pYQuBvDbbTPOq8h4AAA=", "accountId": "sbx_fda58c5980_outlook" }, "metadata": null }, "provider": "microsoft", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="events-attachments-delete" tag="DELETE" label="/v2/events/{eventId}/attachments/{attachmentId}"} Delete an attachment :: ::row :::column **In the sandbox:** Use an ID from `GET /v2/events/{eventId}/attachments`. Deleting an attachment that no longer exists also returns `200`, on every provider. :try-it{operation="events.attachments.delete"} ### Path parameters ::::properties :::::property{name="eventId" type="string" required} The ID of the event. ::::: :::::property{name="attachmentId" type="string" required} ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::::property{name="calendarRef" type="string"} The calendar to use. Defaults to `primary`. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) (422): The user revoked access, or the provider no longer accepts the stored credentials. - [`CONNECTION_TOKEN_EXPIRED`](/errors/CONNECTION_TOKEN_EXPIRED) (422): The provider's access token expired and could not be refreshed. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. - [`PROVIDER_TIMEOUT`](/errors/PROVIDER_TIMEOUT) (504): The calendar provider did not respond in time. ::: :::column{sticky} ::::code-group{title="Request" tag="DELETE" label="/v2/events/{eventId}/attachments/{attachmentId}"} ```bash [cURL] curl -X DELETE 'https://api.pyas.io/v2/events/sg314c0fd5415a58a532cd018d/attachments/1tTOPj3dD9zJTxnuHOHkvShgQGzYdlWUb?accountId=sbx_fda58c5980_google' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.events.attachments.delete({ "eventId": "sg314c0fd5415a58a532cd018d", "attachmentId": "1tTOPj3dD9zJTxnuHOHkvShgQGzYdlWUb", "accountId": "sbx_fda58c5980_google" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": null, "provider": "google", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="events-attachments-upload" tag="POST" label="/v2/events/{eventId}/attachments/file"} Upload a file to an event :: ::row :::column Uploads a file and attaches it to the event. Send the file as the request body with its own `Content-Type`. Send JSON files as `application/octet-stream` with `?contentType=`. With a `Content-Length` header, the file is streamed to the provider. On Google Calendar, the file is stored in Google Drive. Files can be up to 25 MB. The account status shows the limit in `capabilities.maxAttachmentBytes`. For a larger file, attach a link instead. **In the sandbox:** Outlook stores the file on the event. Google uploads it to the person's Google Drive and attaches it. The sandbox stores files up to 4 MB. :try-it{operation="events.attachments.upload"} ### Path parameters ::::properties :::::property{name="eventId" type="string" required} The ID of the event. ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::::property{name="calendarRef" type="string"} The calendar to use. Defaults to `primary`. ::::: :::::property{name="name" type="string" required} The file name. ::::: :::::property{name="contentType" type="string"} The file's media type, when the request's own `Content-Type` can't specify it. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`VALIDATION_PAYLOAD_TOO_LARGE`](/errors/VALIDATION_PAYLOAD_TOO_LARGE) (413): The request body exceeds the size limit for this endpoint. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) (422): The user revoked access, or the provider no longer accepts the stored credentials. - [`CONNECTION_TOKEN_EXPIRED`](/errors/CONNECTION_TOKEN_EXPIRED) (422): The provider's access token expired and could not be refreshed. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. - [`PROVIDER_TIMEOUT`](/errors/PROVIDER_TIMEOUT) (504): The calendar provider did not respond in time. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/events/{eventId}/attachments/file"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/events/AAMkABPQiivgqLU9tm9fMz97PHOQGGb0wy-xR-Eg22SuOCP8AAA%3D/attachments/file?accountId=sbx_fda58c5980_outlook&name=notes.txt' \ -H "x-api-key: $PYAS_API_KEY" \ -F "file=@agenda.pdf" ``` :::: ::::code-group{title="Response" label="201 Created"} ```json { "success": true, "data": { "id": "AAMkAdMhPAQjkB4bvKK-QcQsbZhkMIgklLZqpoVA-IeLqlhIAAA=", "name": "notes.txt", "mimeType": "application/octet-stream", "size": 28, "url": null, "kind": "file" }, "provider": "microsoft", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="events-reminder-dismiss" tag="POST" label="/v2/events/{eventId}/reminder/dismiss"} Dismiss a reminder :: ::row :::column Dismisses the event's reminder. Outlook only. :try-it{operation="events.reminder.dismiss"} ### Path parameters ::::properties :::::property{name="eventId" type="string" required} The ID of the event. ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::::property{name="calendarRef" type="string"} The calendar to use. Defaults to `primary`. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="accountId" type="string"} ::::: :::::property{name="calendarRef" type="string"} ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) (422): The user revoked access, or the provider no longer accepts the stored credentials. - [`CONNECTION_TOKEN_EXPIRED`](/errors/CONNECTION_TOKEN_EXPIRED) (422): The provider's access token expired and could not be refreshed. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. - [`PROVIDER_TIMEOUT`](/errors/PROVIDER_TIMEOUT) (504): The calendar provider did not respond in time. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/events/{eventId}/reminder/dismiss"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/events/AAMkABPQiivgqLU9tm9fMz97PHOQGGb0wy-xR-Eg22SuOCP8AAA%3D/reminder/dismiss?accountId=sbx_fda58c5980_outlook' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{}' ``` ```js [Node.js] const result = await pyas.events.reminder.dismiss({ "eventId": "AAMkABPQiivgqLU9tm9fMz97PHOQGGb0wy-xR-Eg22SuOCP8AAA=", "accountId": "sbx_fda58c5980_outlook" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": null, "provider": "microsoft", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="events-reminder-snooze" tag="POST" label="/v2/events/{eventId}/reminder/snooze"} Snooze a reminder :: ::row :::column Shows the reminder again at a later time. Outlook only. **In the sandbox:** Reminder state is available only on Outlook. For Google accounts, the API returns an error, because Google's API doesn't store reminder state. :try-it{operation="events.reminder.snooze"} ### Path parameters ::::properties :::::property{name="eventId" type="string" required} The ID of the event. ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::::property{name="calendarRef" type="string"} The calendar to use. Defaults to `primary`. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="accountId" type="string"} ::::: :::::property{name="calendarRef" type="string"} ::::: :::::property{name="until" type="string (date-time)" required} When to show the reminder again, as a future ISO 8601 time with a zone. ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) (422): The user revoked access, or the provider no longer accepts the stored credentials. - [`CONNECTION_TOKEN_EXPIRED`](/errors/CONNECTION_TOKEN_EXPIRED) (422): The provider's access token expired and could not be refreshed. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. - [`PROVIDER_TIMEOUT`](/errors/PROVIDER_TIMEOUT) (504): The calendar provider did not respond in time. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/events/{eventId}/reminder/snooze"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/events/AAMkABPQiivgqLU9tm9fMz97PHOQGGb0wy-xR-Eg22SuOCP8AAA%3D/reminder/snooze?accountId=sbx_fda58c5980_outlook' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "until": "2026-11-15T09:55:00Z" }' ``` ```js [Node.js] const result = await pyas.events.reminder.snooze({ "eventId": "AAMkABPQiivgqLU9tm9fMz97PHOQGGb0wy-xR-Eg22SuOCP8AAA=", "accountId": "sbx_fda58c5980_outlook", "until": "2026-11-15T09:55:00Z" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": null, "provider": "microsoft", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="events-attachments-content" tag="GET" label="/v2/events/{eventId}/attachments/{attachmentId}/content"} Download an attachment :: ::row :::column Returns the file contents of an Outlook attachment. For Google Drive files and links, responds with `303 See Other` and the file's location. **In the sandbox:** A file on an Outlook event is returned as its bytes. A link or a Google Drive file is returned as a redirect to its location. :try-it{operation="events.attachments.content"} ### Path parameters ::::properties :::::property{name="eventId" type="string" required} The ID of the event. ::::: :::::property{name="attachmentId" type="string" required} ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string"} The `accountId` of the connected account. Required here or in the request body of a write. ::::: :::::property{name="calendarRef" type="string"} The calendar to use. Defaults to `primary`. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) (422): The user revoked access, or the provider no longer accepts the stored credentials. - [`CONNECTION_TOKEN_EXPIRED`](/errors/CONNECTION_TOKEN_EXPIRED) (422): The provider's access token expired and could not be refreshed. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. - [`PROVIDER_TIMEOUT`](/errors/PROVIDER_TIMEOUT) (504): The calendar provider did not respond in time. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/events/{eventId}/attachments/{attachmentId}/content"} ```bash [cURL] curl 'https://api.pyas.io/v2/events/AAMkABPQiivgqLU9tm9fMz97PHOQGGb0wy-xR-Eg22SuOCP8AAA%3D/attachments/AAMkArXFqduOUNsr8qIMCtJ66XOMYtyB4ZLBHoqtmM72Np-gAAA%3D/content?accountId=sbx_fda58c5980_outlook' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.events.attachments.content({ "eventId": "AAMkABPQiivgqLU9tm9fMz97PHOQGGb0wy-xR-Eg22SuOCP8AAA=", "attachmentId": "AAMkArXFqduOUNsr8qIMCtJ66XOMYtyB4ZLBHoqtmM72Np-gAAA=", "accountId": "sbx_fda58c5980_outlook" }) ``` :::: ::: :: # Availability ::lead Check free/busy information, find meeting times and find rooms. :: ::note The examples use IDs from a sample sandbox. Your sandbox has the same people, calendars and events with its own IDs, which `GET /v2/sandbox` returns. When you're signed in to the dashboard, **Try it** fills in your IDs and sends the request to your sandbox, never to live data. :: --- ::section-heading{id="freebusy-get" tag="GET" label="/v2/free-busy"} Get free/busy for one account :: ::row :::column Returns the same response as `POST /v2/free-busy`, for one account. The v1 parameter names still work, and responses to requests that use them include a `Pyas-Deprecated-Fields` header. :try-it{operation="freeBusy.get"} ### Query parameters ::::properties :::::property{name="accountId" type="string" required} The `accountId` of the connected account. ::::: :::::property{name="calendarRefs" type="string"} The calendars to read, comma-separated. Defaults to `primary`. ::::: :::::property{name="from" type="string"} The start of the time window, as an ISO 8601 time with a zone. ::::: :::::property{name="to" type="string"} The end of the time window, at most 92 days after `from`. ::::: :::::property{name="calendarIds" type="string"} The v1 name for `calendarRefs`. ::::: :::::property{name="timeMin" type="string"} The v1 name for `from`. ::::: :::::property{name="timeMax" type="string"} The v1 name for `to`. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/free-busy"} ```bash [cURL] curl 'https://api.pyas.io/v2/free-busy?accountId=sbx_fda58c5980_1&from=2026-11-14T00%3A00%3A00Z&to=2026-11-21T00%3A00%3A00Z' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.freeBusy.get({ "accountId": "sbx_fda58c5980_1", "from": "2026-11-14T00:00:00Z", "to": "2026-11-21T00:00:00Z" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "busy": [ { "startsAt": "2026-11-14T15:00:00Z", "endsAt": "2026-11-14T16:00:00Z", "status": "busy" }, { "startsAt": "2026-11-15T14:30:00Z", "endsAt": "2026-11-15T14:45:00Z", "status": "busy" }, { "startsAt": "2026-11-15T16:00:00Z", "endsAt": "2026-11-15T17:00:00Z", "status": "busy" }, { "startsAt": "2026-11-15T19:00:00Z", "endsAt": "2026-11-15T19:30:00Z", "status": "busy" }, { "startsAt": "2026-11-15T20:00:00Z", "endsAt": "2026-11-15T21:00:00Z", "status": "busy" }, { "startsAt": "2026-11-16T14:30:00Z", "endsAt": "2026-11-16T14:45:00Z", "status": "busy" }, { "startsAt": "2026-11-16T21:00:00Z", "endsAt": "2026-11-16T21:30:00Z", "status": "busy" }, { "startsAt": "2026-11-17T14:30:00Z", "endsAt": "2026-11-17T14:45:00Z", "status": "busy" }, { "startsAt": "2026-11-17T18:00:00Z", "endsAt": "2026-11-17T20:00:00Z", "status": "busy" }, { "startsAt": "2026-11-18T14:30:00Z", "endsAt": "2026-11-18T14:45:00Z", "status": "busy" }, { "startsAt": "2026-11-19T13:00:00Z", "endsAt": "2026-11-19T14:00:00Z", "status": "busy" }, { "startsAt": "2026-11-19T14:30:00Z", "endsAt": "2026-11-19T14:45:00Z", "status": "busy" } ], "accounts": [ { "accountId": "sbx_fda58c5980_1", "provider": "pyas", "busy": [ { "startsAt": "2026-11-14T15:00:00Z", "endsAt": "2026-11-14T16:00:00Z", "status": "busy" }, { "startsAt": "2026-11-15T14:30:00Z", "endsAt": "2026-11-15T14:45:00Z", "status": "busy" }, { "startsAt": "2026-11-15T16:00:00Z", "endsAt": "2026-11-15T17:00:00Z", "status": "busy" }, { "startsAt": "2026-11-15T19:00:00Z", "endsAt": "2026-11-15T19:30:00Z", "status": "busy" }, { "startsAt": "2026-11-15T20:00:00Z", "endsAt": "2026-11-15T21:00:00Z", "status": "busy" }, { "startsAt": "2026-11-16T14:30:00Z", "endsAt": "2026-11-16T14:45:00Z", "status": "busy" }, { "startsAt": "2026-11-16T21:00:00Z", "endsAt": "2026-11-16T21:30:00Z", "status": "busy" }, { "startsAt": "2026-11-17T14:30:00Z", "endsAt": "2026-11-17T14:45:00Z", "status": "busy" }, { "startsAt": "2026-11-17T18:00:00Z", "endsAt": "2026-11-17T20:00:00Z", "status": "busy" }, { "startsAt": "2026-11-18T14:30:00Z", "endsAt": "2026-11-18T14:45:00Z", "status": "busy" }, { "startsAt": "2026-11-19T13:00:00Z", "endsAt": "2026-11-19T14:00:00Z", "status": "busy" }, { "startsAt": "2026-11-19T14:30:00Z", "endsAt": "2026-11-19T14:45:00Z", "status": "busy" } ], "calendars": { "primary": [ { "startsAt": "2026-11-14T15:00:00Z", "endsAt": "2026-11-14T16:00:00Z", "status": "busy" }, { "startsAt": "2026-11-15T14:30:00Z", "endsAt": "2026-11-15T14:45:00Z", "status": "busy" }, { "startsAt": "2026-11-15T16:00:00Z", "endsAt": "2026-11-15T17:00:00Z", "status": "busy" }, { "startsAt": "2026-11-15T19:00:00Z", "endsAt": "2026-11-15T19:30:00Z", "status": "busy" }, { "startsAt": "2026-11-15T20:00:00Z", "endsAt": "2026-11-15T21:00:00Z", "status": "busy" }, { "startsAt": "2026-11-16T14:30:00Z", "endsAt": "2026-11-16T14:45:00Z", "status": "busy" }, { "startsAt": "2026-11-16T21:00:00Z", "endsAt": "2026-11-16T21:30:00Z", "status": "busy" }, { "startsAt": "2026-11-17T14:30:00Z", "endsAt": "2026-11-17T14:45:00Z", "status": "busy" }, { "startsAt": "2026-11-17T18:00:00Z", "endsAt": "2026-11-17T20:00:00Z", "status": "busy" }, { "startsAt": "2026-11-18T14:30:00Z", "endsAt": "2026-11-18T14:45:00Z", "status": "busy" }, { "startsAt": "2026-11-19T13:00:00Z", "endsAt": "2026-11-19T14:00:00Z", "status": "busy" }, { "startsAt": "2026-11-19T14:30:00Z", "endsAt": "2026-11-19T14:45:00Z", "status": "busy" } ] } } ] }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": { "from": "2026-11-14T00:00:00Z", "to": "2026-11-21T00:00:00Z", "partial": false } } ``` :::: ::: :: --- ::section-heading{id="freebusy-query" tag="POST" label="/v2/free-busy"} Get free/busy for several accounts :: ::row :::column Returns when each account is busy on the requested calendars, merged per account and across all accounts, so you can find when anyone is busy in one request. If one account fails, the error is reported on that account and `meta.partial` is `true`. :try-it{operation="freeBusy.query"} ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="accountIds" type="array of string"} Up to 20 account IDs. Each account is read on the calendars in `calendarRefs`. Send exactly one of `accountIds`, `subjectId` or `accounts`. ::::: :::::property{name="subjectId" type="string"} Selects every Google Calendar, Outlook or virtual calendar account labeled with this `subjectId`. ::::: :::::property{name="accounts" type="array of object"} Accounts, each with its own list of calendars. ::::: :::::property{name="calendarRefs" type="array of string"} Up to 20 calendars to read, as `calendarRef` values. Defaults to `primary`. ::::: :::::property{name="from" type="string (date-time)" required} The start of the time window, as an ISO 8601 time with a zone. ::::: :::::property{name="to" type="string (date-time)" required} The end of the time window, at most 92 days after `from`. ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/free-busy"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/free-busy' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "accountIds": [ "sbx_fda58c5980_1", "sbx_fda58c5980_2" ], "from": "2026-11-14T00:00:00Z", "to": "2026-11-21T00:00:00Z" }' ``` ```js [Node.js] const result = await pyas.freeBusy.query({ "accountIds": [ "sbx_fda58c5980_1", "sbx_fda58c5980_2" ], "from": "2026-11-14T00:00:00Z", "to": "2026-11-21T00:00:00Z" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "busy": [ { "startsAt": "2026-11-14T10:00:00Z", "endsAt": "2026-11-14T11:00:00Z", "status": "busy" }, { "startsAt": "2026-11-14T15:00:00Z", "endsAt": "2026-11-14T16:00:00Z", "status": "busy" }, { "startsAt": "2026-11-15T09:30:00Z", "endsAt": "2026-11-15T09:45:00Z", "status": "busy" }, { "startsAt": "2026-11-15T14:00:00Z", "endsAt": "2026-11-15T14:45:00Z", "status": "busy" }, { "startsAt": "2026-11-15T15:00:00Z", "endsAt": "2026-11-15T17:00:00Z", "status": "busy" }, { "startsAt": "2026-11-15T19:00:00Z", "endsAt": "2026-11-15T19:30:00Z", "status": "busy" }, { "startsAt": "2026-11-15T20:00:00Z", "endsAt": "2026-11-15T21:00:00Z", "status": "busy" }, { "startsAt": "2026-11-16T09:30:00Z", "endsAt": "2026-11-16T09:45:00Z", "status": "busy" }, { "startsAt": "2026-11-16T14:30:00Z", "endsAt": "2026-11-16T14:45:00Z", "status": "busy" }, { "startsAt": "2026-11-16T16:00:00Z", "endsAt": "2026-11-16T16:30:00Z", "status": "busy" }, { "startsAt": "2026-11-16T21:00:00Z", "endsAt": "2026-11-16T21:30:00Z", "status": "busy" }, { "startsAt": "2026-11-17T09:30:00Z", "endsAt": "2026-11-17T09:45:00Z", "status": "busy" }, { "startsAt": "2026-11-17T10:00:00Z", "endsAt": "2026-11-17T11:00:00Z", "status": "busy" }, { "startsAt": "2026-11-17T13:00:00Z", "endsAt": "2026-11-17T15:00:00Z", "status": "busy" }, { "startsAt": "2026-11-17T18:00:00Z", "endsAt": "2026-11-17T20:00:00Z", "status": "busy" }, { "startsAt": "2026-11-18T09:30:00Z", "endsAt": "2026-11-18T09:45:00Z", "status": "busy" }, { "startsAt": "2026-11-18T14:30:00Z", "endsAt": "2026-11-18T14:45:00Z", "status": "busy" }, { "startsAt": "2026-11-19T08:00:00Z", "endsAt": "2026-11-19T09:00:00Z", "status": "busy" }, { "startsAt": "2026-11-19T09:30:00Z", "endsAt": "2026-11-19T09:45:00Z", "status": "busy" }, { "startsAt": "2026-11-19T13:00:00Z", "endsAt": "2026-11-19T14:00:00Z", "status": "busy" }, { "startsAt": "2026-11-19T14:30:00Z", "endsAt": "2026-11-19T14:45:00Z", "status": "busy" }, { "startsAt": "2026-11-19T18:00:00Z", "endsAt": "2026-11-19T19:00:00Z", "status": "busy" } ], "accounts": [ { "accountId": "sbx_fda58c5980_1", "provider": "pyas", "busy": [ { "startsAt": "2026-11-14T15:00:00Z", "endsAt": "2026-11-14T16:00:00Z", "status": "busy" }, { "startsAt": "2026-11-15T14:30:00Z", "endsAt": "2026-11-15T14:45:00Z", "status": "busy" }, { "startsAt": "2026-11-15T16:00:00Z", "endsAt": "2026-11-15T17:00:00Z", "status": "busy" }, { "startsAt": "2026-11-15T19:00:00Z", "endsAt": "2026-11-15T19:30:00Z", "status": "busy" }, { "startsAt": "2026-11-15T20:00:00Z", "endsAt": "2026-11-15T21:00:00Z", "status": "busy" }, { "startsAt": "2026-11-16T14:30:00Z", "endsAt": "2026-11-16T14:45:00Z", "status": "busy" }, { "startsAt": "2026-11-16T21:00:00Z", "endsAt": "2026-11-16T21:30:00Z", "status": "busy" }, { "startsAt": "2026-11-17T10:00:00Z", "endsAt": "2026-11-17T11:00:00Z", "status": "busy" }, { "startsAt": "2026-11-17T14:30:00Z", "endsAt": "2026-11-17T14:45:00Z", "status": "busy" }, { "startsAt": "2026-11-17T18:00:00Z", "endsAt": "2026-11-17T20:00:00Z", "status": "busy" }, { "startsAt": "2026-11-18T14:30:00Z", "endsAt": "2026-11-18T14:45:00Z", "status": "busy" }, { "startsAt": "2026-11-19T13:00:00Z", "endsAt": "2026-11-19T14:00:00Z", "status": "busy" }, { "startsAt": "2026-11-19T14:30:00Z", "endsAt": "2026-11-19T14:45:00Z", "status": "busy" }, { "startsAt": "2026-11-19T18:00:00Z", "endsAt": "2026-11-19T19:00:00Z", "status": "busy" } ], "calendars": { "primary": [ { "startsAt": "2026-11-14T15:00:00Z", "endsAt": "2026-11-14T16:00:00Z", "status": "busy" }, { "startsAt": "2026-11-15T14:30:00Z", "endsAt": "2026-11-15T14:45:00Z", "status": "busy" }, { "startsAt": "2026-11-15T16:00:00Z", "endsAt": "2026-11-15T17:00:00Z", "status": "busy" }, { "startsAt": "2026-11-15T19:00:00Z", "endsAt": "2026-11-15T19:30:00Z", "status": "busy" }, { "startsAt": "2026-11-15T20:00:00Z", "endsAt": "2026-11-15T21:00:00Z", "status": "busy" }, { "startsAt": "2026-11-16T14:30:00Z", "endsAt": "2026-11-16T14:45:00Z", "status": "busy" }, { "startsAt": "2026-11-16T21:00:00Z", "endsAt": "2026-11-16T21:30:00Z", "status": "busy" }, { "startsAt": "2026-11-17T10:00:00Z", "endsAt": "2026-11-17T11:00:00Z", "status": "busy" }, { "startsAt": "2026-11-17T14:30:00Z", "endsAt": "2026-11-17T14:45:00Z", "status": "busy" }, { "startsAt": "2026-11-17T18:00:00Z", "endsAt": "2026-11-17T20:00:00Z", "status": "busy" }, { "startsAt": "2026-11-18T14:30:00Z", "endsAt": "2026-11-18T14:45:00Z", "status": "busy" }, { "startsAt": "2026-11-19T13:00:00Z", "endsAt": "2026-11-19T14:00:00Z", "status": "busy" }, { "startsAt": "2026-11-19T14:30:00Z", "endsAt": "2026-11-19T14:45:00Z", "status": "busy" }, { "startsAt": "2026-11-19T18:00:00Z", "endsAt": "2026-11-19T19:00:00Z", "status": "busy" } ] } }, { "accountId": "sbx_fda58c5980_2", "provider": "pyas", "busy": [ { "startsAt": "2026-11-14T10:00:00Z", "endsAt": "2026-11-14T11:00:00Z", "status": "busy" }, { "startsAt": "2026-11-15T09:30:00Z", "endsAt": "2026-11-15T09:45:00Z", "status": "busy" }, { "startsAt": "2026-11-15T14:00:00Z", "endsAt": "2026-11-15T14:30:00Z", "status": "busy" }, { "startsAt": "2026-11-15T15:00:00Z", "endsAt": "2026-11-15T16:00:00Z", "status": "busy" }, { "startsAt": "2026-11-16T09:30:00Z", "endsAt": "2026-11-16T09:45:00Z", "status": "busy" }, { "startsAt": "2026-11-16T16:00:00Z", "endsAt": "2026-11-16T16:30:00Z", "status": "busy" }, { "startsAt": "2026-11-17T09:30:00Z", "endsAt": "2026-11-17T09:45:00Z", "status": "busy" }, { "startsAt": "2026-11-17T13:00:00Z", "endsAt": "2026-11-17T15:00:00Z", "status": "busy" }, { "startsAt": "2026-11-18T09:30:00Z", "endsAt": "2026-11-18T09:45:00Z", "status": "busy" }, { "startsAt": "2026-11-19T08:00:00Z", "endsAt": "2026-11-19T09:00:00Z", "status": "busy" }, { "startsAt": "2026-11-19T09:30:00Z", "endsAt": "2026-11-19T09:45:00Z", "status": "busy" } ], "calendars": { "primary": [ { "startsAt": "2026-11-14T10:00:00Z", "endsAt": "2026-11-14T11:00:00Z", "status": "busy" }, { "startsAt": "2026-11-15T09:30:00Z", "endsAt": "2026-11-15T09:45:00Z", "status": "busy" }, { "startsAt": "2026-11-15T14:00:00Z", "endsAt": "2026-11-15T14:30:00Z", "status": "busy" }, { "startsAt": "2026-11-15T15:00:00Z", "endsAt": "2026-11-15T16:00:00Z", "status": "busy" }, { "startsAt": "2026-11-16T09:30:00Z", "endsAt": "2026-11-16T09:45:00Z", "status": "busy" }, { "startsAt": "2026-11-16T16:00:00Z", "endsAt": "2026-11-16T16:30:00Z", "status": "busy" }, { "startsAt": "2026-11-17T09:30:00Z", "endsAt": "2026-11-17T09:45:00Z", "status": "busy" }, { "startsAt": "2026-11-17T13:00:00Z", "endsAt": "2026-11-17T15:00:00Z", "status": "busy" }, { "startsAt": "2026-11-18T09:30:00Z", "endsAt": "2026-11-18T09:45:00Z", "status": "busy" }, { "startsAt": "2026-11-19T08:00:00Z", "endsAt": "2026-11-19T09:00:00Z", "status": "busy" }, { "startsAt": "2026-11-19T09:30:00Z", "endsAt": "2026-11-19T09:45:00Z", "status": "busy" } ] } } ] }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": { "from": "2026-11-14T00:00:00Z", "to": "2026-11-21T00:00:00Z", "partial": false } } ``` :::: ::: :: --- ::section-heading{id="meetingtimes-find" tag="POST" label="/v2/meeting-times"} Find meeting times :: ::row :::column Finds free time slots common to several people across Google Calendar, Outlook and virtual calendars. Slots respect each attendee's working hours, buffers and minimum notice, and can include a room. :try-it{operation="meetingTimes.find"} ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="attendees" type="array of object"} The required and optional attendees. Send this or `accountIds`. At most 20 items. ::::: :::::property{name="accountIds" type="array of string"} A shorthand for `attendees`. Each account is a required attendee, read on its primary calendar. ::::: :::::property{name="from" type="string (date-time)" required} The start of the time window to search. ::::: :::::property{name="to" type="string (date-time)" required} The end of the time window, at most 62 days after `from`. ::::: :::::property{name="durationMinutes" type="integer"} The length of the meeting in minutes. Defaults to 30. From 5 to 1440. ::::: :::::property{name="timezone" type="string"} The IANA time zone that slots line up with (on the hour or half hour there). Slot times are always returned in UTC. Defaults to UTC. ::::: :::::property{name="workingHours" type="object"} The working hours to use for attendees whose provider doesn't report them. Without a `timezone`, they apply in each attendee's own time zone. Defaults to 09:00 to 17:00, Monday to Friday. ::::: :::::property{name="workingHours.days" type="array of string" required} One of `sunday`, `monday`, `tuesday`, `wednesday`, `thursday`, `friday`, `saturday`. ::::: :::::property{name="workingHours.start" type="string" required} ::::: :::::property{name="workingHours.end" type="string" required} ::::: :::::property{name="workingHours.timezone" type="string"} ::::: :::::property{name="respectWorkingHours" type="boolean"} Whether to return only slots within working hours. Defaults to `true`. ::::: :::::property{name="buffer" type="object"} The minutes to keep free before and after other events. ::::: :::::property{name="buffer.before" type="integer"} From 0 to 240. ::::: :::::property{name="buffer.after" type="integer"} From 0 to 240. ::::: :::::property{name="minimumNoticeMinutes" type="integer"} The minimum number of minutes between now and the start of a slot. From 0 to 43200. ::::: :::::property{name="slotMinutes" type="integer"} The interval between slot start times, in minutes: 5, 10, 15, 20, 30 or 60. Defaults to 30. From 5 to 60. ::::: :::::property{name="tentativeIsBusy" type="boolean"} Whether to count tentative events as busy. Defaults to `true`. ::::: :::::property{name="limit" type="integer"} The maximum number of slots to return. Defaults to 20. From 1 to 100. ::::: :::::property{name="rooms" type="object"} Finds a free room as well, from the listed room email addresses or from the organization's matching rooms. ::::: :::::property{name="rooms.accountId" type="string" required} The account whose organization the rooms are in. ::::: :::::property{name="rooms.emails" type="array of string"} At most 20 items. ::::: :::::property{name="rooms.minCapacity" type="integer"} From 1 to 10000. ::::: :::::property{name="rooms.building" type="string"} ::::: :::::property{name="rooms.required" type="boolean"} Whether to return only slots with a free room. Defaults to `true`. ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/meeting-times"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/meeting-times' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "accountIds": [ "sbx_fda58c5980_1", "sbx_fda58c5980_2" ], "from": "2026-11-15T00:00:00Z", "to": "2026-11-22T00:00:00Z", "durationMinutes": 30, "timezone": "America/New_York" }' ``` ```js [Node.js] const result = await pyas.meetingTimes.find({ "accountIds": [ "sbx_fda58c5980_1", "sbx_fda58c5980_2" ], "from": "2026-11-15T00:00:00Z", "to": "2026-11-22T00:00:00Z", "durationMinutes": 30, "timezone": "America/New_York" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": [ { "startsAt": "2026-11-16T14:00:00Z", "endsAt": "2026-11-16T14:30:00Z", "optionalFree": [], "optionalBusy": [] }, { "startsAt": "2026-11-16T15:00:00Z", "endsAt": "2026-11-16T15:30:00Z", "optionalFree": [], "optionalBusy": [] }, { "startsAt": "2026-11-16T15:30:00Z", "endsAt": "2026-11-16T16:00:00Z", "optionalFree": [], "optionalBusy": [] }, { "startsAt": "2026-11-16T16:30:00Z", "endsAt": "2026-11-16T17:00:00Z", "optionalFree": [], "optionalBusy": [] }, { "startsAt": "2026-11-17T15:00:00Z", "endsAt": "2026-11-17T15:30:00Z", "optionalFree": [], "optionalBusy": [] }, { "startsAt": "2026-11-17T15:30:00Z", "endsAt": "2026-11-17T16:00:00Z", "optionalFree": [], "optionalBusy": [] }, { "startsAt": "2026-11-17T16:00:00Z", "endsAt": "2026-11-17T16:30:00Z", "optionalFree": [], "optionalBusy": [] }, { "startsAt": "2026-11-17T16:30:00Z", "endsAt": "2026-11-17T17:00:00Z", "optionalFree": [], "optionalBusy": [] }, { "startsAt": "2026-11-18T14:00:00Z", "endsAt": "2026-11-18T14:30:00Z", "optionalFree": [], "optionalBusy": [] }, { "startsAt": "2026-11-18T15:00:00Z", "endsAt": "2026-11-18T15:30:00Z", "optionalFree": [], "optionalBusy": [] }, { "startsAt": "2026-11-18T15:30:00Z", "endsAt": "2026-11-18T16:00:00Z", "optionalFree": [], "optionalBusy": [] }, { "startsAt": "2026-11-18T16:00:00Z", "endsAt": "2026-11-18T16:30:00Z", "optionalFree": [], "optionalBusy": [] }, { "startsAt": "2026-11-18T16:30:00Z", "endsAt": "2026-11-18T17:00:00Z", "optionalFree": [], "optionalBusy": [] }, { "startsAt": "2026-11-19T14:00:00Z", "endsAt": "2026-11-19T14:30:00Z", "optionalFree": [], "optionalBusy": [] }, { "startsAt": "2026-11-19T15:00:00Z", "endsAt": "2026-11-19T15:30:00Z", "optionalFree": [], "optionalBusy": [] }, { "startsAt": "2026-11-19T15:30:00Z", "endsAt": "2026-11-19T16:00:00Z", "optionalFree": [], "optionalBusy": [] }, { "startsAt": "2026-11-19T16:00:00Z", "endsAt": "2026-11-19T16:30:00Z", "optionalFree": [], "optionalBusy": [] }, { "startsAt": "2026-11-19T16:30:00Z", "endsAt": "2026-11-19T17:00:00Z", "optionalFree": [], "optionalBusy": [] } ], "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": { "timezone": "America/New_York", "attendees": [ { "accountId": "sbx_fda58c5980_1", "optional": false, "workingHours": { "days": [ "monday", "tuesday", "wednesday", "thursday", "friday" ], "start": "09:00", "end": "17:00", "timezone": "America/New_York", "source": "default" } }, { "accountId": "sbx_fda58c5980_2", "optional": false, "workingHours": { "days": [ "monday", "tuesday", "wednesday", "thursday", "friday" ], "start": "09:00", "end": "17:00", "timezone": "Europe/London", "source": "default" } } ], "partial": false } } ``` :::: ::: :: --- ::section-heading{id="rooms-list" tag="GET" label="/v2/rooms"} List rooms and resources :: ::row :::column Returns the rooms and resources of the account's organization, from the Google Workspace resource directory or from the Microsoft Outlook room list. Both providers require an administrator's consent, and the error explains when it is missing. To book a room, add its email address to an event's `rooms`. :try-it{operation="rooms.list"} ### Query parameters ::::properties :::::property{name="accountId" type="string" required} The Pyas `accountId` of the connected account. ::::: :::::property{name="minCapacity" type="integer"} Returns only rooms with at least this many seats. From 1 to 10000. ::::: :::::property{name="building" type="string"} In this building (its name, any case). ::::: :::::property{name="feature" type="string"} Returns only rooms with a feature that contains this text, such as `video`. ::::: :::::property{name="q" type="string"} Returns only rooms whose name or email address contains this text. ::::: :::::property{name="kind" type="string"} One of `room`, `resource`. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/rooms"} ```bash [cURL] curl 'https://api.pyas.io/v2/rooms?accountId=sbx_fda58c5980_google&minCapacity=6' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.rooms.list({ "accountId": "sbx_fda58c5980_google", "minCapacity": "6" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": [ { "id": "c_1880boardroom", "name": "Boardroom", "email": "c_1880boardroom@resource.calendar.google.com", "capacity": 12, "building": "HQ", "floor": "3", "features": [ "Video conferencing", "Whiteboard" ], "accessible": null, "kind": "room" }, { "id": "c_1880room4b", "name": "Room 4B", "email": "c_1880room4b@resource.calendar.google.com", "capacity": 6, "building": "HQ", "floor": "4", "features": [ "Display" ], "accessible": null, "kind": "room" } ], "provider": "google", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: # Meetings ::lead Meetings on their own, with no calendar event: Zoom meetings (list, get, create, change, delete), Microsoft Teams meetings (get, create, change, delete) and Google Meet links (get, create, change settings). To add a meeting to an event, use conferencing on the event. :: ::note The examples use IDs from a sample sandbox. Your sandbox has the same people, calendars and events with its own IDs, which `GET /v2/sandbox` returns. When you're signed in to the dashboard, **Try it** fills in your IDs and sends the request to your sandbox, never to live data. :: --- ::section-heading{id="meetings-list" tag="GET" label="/v2/meetings"} List meetings :: ::row :::column Lists a Zoom account's meetings, the next ones first. `type` chooses which: `upcoming` (the default), `scheduled`, `live` or `previous`. Microsoft Teams and Google Meet have no list of meetings made with no event, so their accounts are rejected: keep the ID the create returns. :try-it{operation="meetings.list"} ### Query parameters ::::properties :::::property{name="accountId" type="string" required} The Pyas `accountId` of the connected account. ::::: :::::property{name="type" type="string"} Which meetings: `upcoming` (the default), `scheduled`, `live` or `previous`. One of `upcoming`, `scheduled`, `live`, `previous`. ::::: :::::property{name="from" type="string (date)"} The first day (YYYY-MM-DD). ::::: :::::property{name="to" type="string (date)"} The last day (YYYY-MM-DD). ::::: :::::property{name="limit" type="integer"} The maximum number of meetings in one page. Defaults to 30. From 1 to 300. ::::: :::::property{name="cursor" type="string"} The `nextCursor` from the previous page. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) (422): The user revoked access, or the provider no longer accepts the stored credentials. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/meetings"} ```bash [cURL] curl 'https://api.pyas.io/v2/meetings?accountId=sbx_fda58c5980_zoom' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.meetings.list({ "accountId": "sbx_fda58c5980_zoom" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": [ { "id": "97474246023", "provider": "zoom", "accountId": "sbx_fda58c5980_zoom", "kind": "recurring", "title": "Team standup", "description": null, "startsAt": "2026-11-15T14:30:00Z", "durationMinutes": 15, "timezone": "America/New_York", "recurrence": null, "occurrences": [], "joinUrl": "https://zoom.us/j/97474246023?pwd=9pnByLlPi43xeCouDVOGZ3GJK8RNuRWi", "passcode": null, "dialIn": [], "settings": {}, "createdAt": "2026-11-14T20:00:23Z" }, { "id": "67556028668", "provider": "zoom", "accountId": "sbx_fda58c5980_zoom", "kind": "scheduled", "title": "Customer onboarding (Zoom)", "description": null, "startsAt": "2026-11-15T16:00:00Z", "durationMinutes": 60, "timezone": "America/New_York", "recurrence": null, "occurrences": [], "joinUrl": "https://zoom.us/j/67556028668?pwd=k1NV6aHSXvDYLT96s3yhRedsmHRIroO0", "passcode": null, "dialIn": [], "settings": {}, "createdAt": "2026-11-14T20:00:24Z" }, { "id": "40824382962", "provider": "zoom", "accountId": "sbx_fda58c5980_zoom", "kind": "scheduled", "title": "Product demo", "description": "Walk through the new scheduling flow.", "startsAt": "2026-11-18T20:00:00Z", "durationMinutes": 45, "timezone": "America/New_York", "recurrence": null, "occurrences": [], "joinUrl": "https://zoom.us/j/40824382962?pwd=uQj_Ps-IiROCfXW0Y-B5v7kTyLW3P6P7", "passcode": null, "dialIn": [], "settings": {}, "createdAt": "2026-11-14T20:00:23Z" } ], "provider": "zoom", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": { "nextCursor": null } } ``` :::: ::: :: --- ::section-heading{id="meetings-create" tag="POST" label="/v2/meetings"} Create a meeting :: ::row :::column Creates a meeting with no calendar event: a Zoom meeting on a Zoom account, a Microsoft Teams meeting on a Microsoft work or school account, or a Google Meet link on a Google account. Zoom meetings always have a passcode: set one in `settings`, or Pyas makes one; Teams makes its own. A Teams meeting with no event happens once and has no description. A Google Meet link has no title or time: send only `accountId` and `settings`. It needs the `meetingSettings` connect feature (Google's `meetings.space.created` permission). To put a meeting on an event, use `conferencing` on `POST /v2/events` instead. :try-it{operation="meetings.create"} ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and doesn't make the change twice. ::::: :::: ### Body ::::properties :::::property{name="accountId" type="string" required} The `accountId` of a connected Zoom account, a Microsoft work or school account for Microsoft Teams, or a Google account for a Google Meet link. ::::: :::::property{name="title" type="string"} The meeting's topic. Required for Zoom and Microsoft Teams; a Google Meet link has none. ::::: :::::property{name="description" type="string"} The agenda. Zoom only. ::::: :::::property{name="startsAt" type="string (date-time)"} When it starts (ISO 8601). For a recurring meeting, its first occurrence. Required for Zoom and Microsoft Teams; a Google Meet link works at any time. ::::: :::::property{name="durationMinutes" type="integer"} Its length in minutes. 30 if you don't set it. From 1 to 1440. ::::: :::::property{name="timezone" type="string"} An IANA time zone, such as `America/New_York`. Recurring meetings repeat in it. ::::: :::::property{name="recurrence" type="string"} Zoom only. A recurrence rule (RRULE): FREQ=DAILY, WEEKLY or MONTHLY, INTERVAL, BYDAY or BYMONTHDAY, and COUNT (up to 60) or UNTIL. Zoom needs an end. ::::: :::::property{name="attendees" type="array of object"} People invited by email. On Microsoft Teams, the meeting's attendees. At most 100 items. ::::: :::::property{name="settings" type="ConferencingOptions"} Meeting settings, with the same field names for Zoom, Microsoft Teams and Google Meet. If the conferencing service doesn't support a setting, the request is rejected with the reason. ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) (422): The user revoked access, or the provider no longer accepts the stored credentials. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`VALIDATION_REJECTED_BY_PROVIDER`](/errors/VALIDATION_REJECTED_BY_PROVIDER) (422): The calendar provider rejected this input. - [`VALIDATION_IDEMPOTENCY_CONFLICT`](/errors/VALIDATION_IDEMPOTENCY_CONFLICT) (422): This idempotency key was already used with a different request. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/meetings"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/meetings' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "accountId": "sbx_fda58c5980_zoom", "title": "Customer kickoff", "startsAt": "2026-11-16T16:00:00Z", "durationMinutes": 45, "timezone": "America/New_York", "settings": { "admission": "organizer" } }' ``` ```js [Node.js] const result = await pyas.meetings.create({ "accountId": "sbx_fda58c5980_zoom", "title": "Customer kickoff", "startsAt": "2026-11-16T16:00:00Z", "durationMinutes": 45, "timezone": "America/New_York", "settings": { "admission": "organizer" } }) ``` :::: ::::code-group{title="Response" label="201 Created"} ```json { "success": true, "data": { "id": "70544193435", "provider": "zoom", "accountId": "sbx_fda58c5980_zoom", "kind": "scheduled", "title": "Customer kickoff", "description": null, "startsAt": "2026-11-16T16:00:00Z", "durationMinutes": 45, "timezone": "America/New_York", "recurrence": null, "occurrences": [], "joinUrl": "https://zoom.us/j/70544193435?pwd=ULy4JwXVyskJ5qGKo71ayxu4dZsVvoGc", "passcode": "Cbx2Q611p0", "dialIn": [ { "label": "US New York", "number": "+1 555 0100", "pin": "661300" } ], "settings": { "admission": "organizer", "recording": "none", "joinBeforeHost": false, "muteOnEntry": false, "coHosts": [] }, "createdAt": "2026-11-14T20:00:25Z" }, "provider": "zoom", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="meetings-get" tag="GET" label="/v2/meetings/{meetingId}"} Get a meeting :: ::row :::column Gets a meeting, with a recurring Zoom meeting's occurrences. :try-it{operation="meetings.get"} ### Path parameters ::::properties :::::property{name="meetingId" type="string" required} The meeting ID, as the create or list returned it. ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string" required} The Pyas `accountId` of the connected account. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) (422): The user revoked access, or the provider no longer accepts the stored credentials. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/meetings/{meetingId}"} ```bash [cURL] curl 'https://api.pyas.io/v2/meetings/97474246023?accountId=sbx_fda58c5980_zoom' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.meetings.get({ "meetingId": "97474246023", "accountId": "sbx_fda58c5980_zoom" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "id": "97474246023", "provider": "zoom", "accountId": "sbx_fda58c5980_zoom", "kind": "recurring", "title": "Team standup", "description": null, "startsAt": "2026-11-15T14:30:00Z", "durationMinutes": 15, "timezone": "America/New_York", "recurrence": "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;COUNT=60", "occurrences": [ { "id": "1794234600000", "startsAt": "2026-11-15T14:30:00Z", "durationMinutes": 15, "status": "available" }, { "id": "1794321000000", "startsAt": "2026-11-16T14:30:00Z", "durationMinutes": 15, "status": "available" }, { "id": "1794407400000", "startsAt": "2026-11-17T14:30:00Z", "durationMinutes": 15, "status": "available" }, { "id": "1794493800000", "startsAt": "2026-11-18T14:30:00Z", "durationMinutes": 15, "status": "available" }, { "id": "1794580200000", "startsAt": "2026-11-19T14:30:00Z", "durationMinutes": 15, "status": "available" }, { "id": "1794839400000", "startsAt": "2026-11-22T14:30:00Z", "durationMinutes": 15, "status": "available" }, { "id": "1794925800000", "startsAt": "2026-11-23T14:30:00Z", "durationMinutes": 15, "status": "available" }, { "id": "1795012200000", "startsAt": "2026-11-24T14:30:00Z", "durationMinutes": 15, "status": "available" }, { "id": "1795098600000", "startsAt": "2026-11-25T14:30:00Z", "durationMinutes": 15, "status": "available" }, { "id": "1795185000000", "startsAt": "2026-11-26T14:30:00Z", "durationMinutes": 15, "status": "available" }, { "id": "1795444200000", "startsAt": "2026-11-29T14:30:00Z", "durationMinutes": 15, "status": "available" }, { "id": "1795530600000", "startsAt": "2026-11-30T14:30:00Z", "durationMinutes": 15, "status": "available" }, { "id": "1795617000000", "startsAt": "2026-12-01T14:30:00Z", "durationMinutes": 15, "status": "available" }, { "id": "1795703400000", "startsAt": "2026-12-02T14:30:00Z", "durationMinutes": 15, "status": "available" }, { "id": "1795789800000", "startsAt": "2026-12-03T14:30:00Z", "durationMinutes": 15, "status": "available" }, { "id": "1796049000000", "startsAt": "2026-12-06T14:30:00Z", "durationMinutes": 15, "status": "available" }, { "id": "1796135400000", "startsAt": "2026-12-07T14:30:00Z", "durationMinutes": 15, "status": "available" }, { "id": "1796221800000", "startsAt": "2026-12-08T14:30:00Z", "durationMinutes": 15, "status": "available" }, { "id": "1796308200000", "startsAt": "2026-12-09T14:30:00Z", "durationMinutes": 15, "status": "available" }, { "id": "1796394600000", "startsAt": "2026-12-10T14:30:00Z", "durationMinutes": 15, "status": "available" } ], "joinUrl": "https://zoom.us/j/97474246023?pwd=9pnByLlPi43xeCouDVOGZ3GJK8RNuRWi", "passcode": "standup1", "dialIn": [ { "label": "US New York", "number": "+1 555 0100", "pin": "863020" } ], "settings": { "admission": "everyone", "recording": "none", "joinBeforeHost": false, "muteOnEntry": false, "coHosts": [] }, "createdAt": "2026-11-14T20:00:23Z" }, "provider": "zoom", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="meetings-update" tag="PATCH" label="/v2/meetings/{meetingId}"} Update a meeting :: ::row :::column Changes only the fields you send, and returns the meeting as it is now. A Google Meet link changes only its settings. :try-it{operation="meetings.update"} ### Path parameters ::::properties :::::property{name="meetingId" type="string" required} The meeting ID, as the create or list returned it. ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string" required} The Pyas `accountId` of the connected account. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and doesn't make the change twice. ::::: :::: ### Body ::::properties :::::property{name="title" type="string"} ::::: :::::property{name="description" type="string | null"} ::::: :::::property{name="startsAt" type="string (date-time)"} ::::: :::::property{name="durationMinutes" type="integer"} From 1 to 1440. ::::: :::::property{name="timezone" type="string"} ::::: :::::property{name="recurrence" type="string | null"} A recurrence rule (RRULE): FREQ=DAILY, WEEKLY or MONTHLY, INTERVAL, BYDAY or BYMONTHDAY, and COUNT (up to 60) or UNTIL. Zoom needs an end. `null` makes it a single meeting. ::::: :::::property{name="attendees" type="array of object"} At most 100 items. ::::: :::::property{name="settings" type="ConferencingOptions"} Meeting settings, with the same field names for Zoom, Microsoft Teams and Google Meet. If the conferencing service doesn't support a setting, the request is rejected with the reason. ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) (422): The user revoked access, or the provider no longer accepts the stored credentials. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`VALIDATION_REJECTED_BY_PROVIDER`](/errors/VALIDATION_REJECTED_BY_PROVIDER) (422): The calendar provider rejected this input. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. ::: :::column{sticky} ::::code-group{title="Request" tag="PATCH" label="/v2/meetings/{meetingId}"} ```bash [cURL] curl -X PATCH 'https://api.pyas.io/v2/meetings/40824382962?accountId=sbx_fda58c5980_zoom' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "title": "Product demo: scheduling flow", "durationMinutes": 60 }' ``` ```js [Node.js] const result = await pyas.meetings.update({ "meetingId": "40824382962", "accountId": "sbx_fda58c5980_zoom", "title": "Product demo: scheduling flow", "durationMinutes": 60 }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "id": "40824382962", "provider": "zoom", "accountId": "sbx_fda58c5980_zoom", "kind": "scheduled", "title": "Product demo: scheduling flow", "description": "Walk through the new scheduling flow.", "startsAt": "2026-11-18T20:00:00Z", "durationMinutes": 60, "timezone": "America/New_York", "recurrence": null, "occurrences": [], "joinUrl": "https://zoom.us/j/40824382962?pwd=uQj_Ps-IiROCfXW0Y-B5v7kTyLW3P6P7", "passcode": "demo2026", "dialIn": [ { "label": "US New York", "number": "+1 555 0100", "pin": "510795" } ], "settings": { "admission": "organizer", "recording": "none", "joinBeforeHost": false, "muteOnEntry": false, "coHosts": [] }, "createdAt": "2026-11-14T20:00:24Z" }, "provider": "zoom", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="meetings-delete" tag="DELETE" label="/v2/meetings/{meetingId}"} Delete a meeting :: ::row :::column Deletes the meeting. With `notify=true`, Zoom emails the host and the people invited that it was canceled. Microsoft Teams sends no cancellation for a meeting with no event, so `notify=true` is rejected there. Google Meet links can't be deleted, so a Google account is rejected. :try-it{operation="meetings.delete"} ### Path parameters ::::properties :::::property{name="meetingId" type="string" required} The meeting ID, as the create or list returned it. ::::: :::: ### Query parameters ::::properties :::::property{name="accountId" type="string" required} The Pyas `accountId` of the connected account. ::::: :::::property{name="notify" type="boolean"} Zoom only. Whether Zoom emails a cancellation. `false` if you don't set it. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and doesn't make the change twice. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) (422): The user revoked access, or the provider no longer accepts the stored credentials. - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) (422): The connected account did not grant a permission that this operation requires. - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) (422): This provider does not support this operation. - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) (429): The calendar provider is rate limiting requests for this account. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) (503): The calendar provider is not responding normally. ::: :::column{sticky} ::::code-group{title="Request" tag="DELETE" label="/v2/meetings/{meetingId}"} ```bash [cURL] curl -X DELETE 'https://api.pyas.io/v2/meetings/40824382962?accountId=sbx_fda58c5980_zoom¬ify=false' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.meetings.delete({ "meetingId": "40824382962", "accountId": "sbx_fda58c5980_zoom", "notify": "false" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": null, "provider": "zoom", "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: # Invitations ::lead Invitations sent for virtual calendars. :: ::note The examples use IDs from a sample sandbox. Your sandbox has the same people, calendars and events with its own IDs, which `GET /v2/sandbox` returns. When you're signed in to the dashboard, **Try it** fills in your IDs and sends the request to your sandbox, never to live data. :: --- ::section-heading{id="invitations-list" tag="GET" label="/v2/invitations"} List invitations :: ::row :::column Lists the invitations that virtual calendars sent for the app, newest first. The sandbox doesn't send invitations. Each one is listed with the status `captured` and the iCalendar text attendees would have received. :try-it{operation="invitations.list"} ### Query parameters ::::properties :::::property{name="accountId" type="string"} ::::: :::::property{name="eventId" type="string"} ::::: :::::property{name="status" type="string"} One of `queued`, `sent`, `captured`, `held`, `suppressed`, `failed`. ::::: :::::property{name="limit" type="integer"} The maximum number of results in one page. Defaults to 50. From 1 to 200. ::::: :::::property{name="cursor" type="string"} The `nextCursor` from the previous page. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/invitations"} ```bash [cURL] curl 'https://api.pyas.io/v2/invitations?accountId=sbx_fda58c5980_1' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.invitations.list({ "accountId": "sbx_fda58c5980_1" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": [], "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": { "nextCursor": null } } ``` :::: ::: :: --- ::section-heading{id="invitations-get" tag="GET" label="/v2/invitations/{invitationId}"} Get an invitation :: ::row :::column **In the sandbox:** Use an ID from `GET /v2/invitations`. An event with attendees on a virtual calendar creates invitations. :try-it{operation="invitations.get"} ### Path parameters ::::properties :::::property{name="invitationId" type="string" required} ::::: :::: ### Errors - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/invitations/{invitationId}"} ```bash [cURL] curl 'https://api.pyas.io/v2/invitations/inv_from_the_list' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.invitations.get({ "invitationId": "inv_from_the_list" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "id": "inv_23871b494bdc3886eaf3bef0", "accountId": "sbx_fda58c5980_1", "calendarId": "calfda58c59801p", "eventId": "evtrsm7egur84pa64mdnoet", "method": "REQUEST", "kind": "new", "to": "sam.patel@example.org", "status": "captured", "reason": null, "subject": "Invitation: Project kickoff @ Tue, Nov 10, 2026 10:00 AM to 11:00 AM (America/New_York)", "createdAt": "2026-11-14T20:00:32.966Z", "sentAt": null, "bounced": false, "complained": false, "ics": "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Pyas//Virtual calendar//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:REQUEST\r\nBEGIN:VTIMEZONE\r\nTZID:America/New_York\r\nBEGIN:STANDARD\r\nDTSTART:19700101T000000\r\nTZOFFSETFROM:-0500\r\nTZOFFSETTO:-0500\r\nEND:STANDARD\r\nBEGIN:DAYLIGHT\r\nDTSTART:20260308T020000\r\nTZOFFSETFROM:-0500\r\nTZOFFSETTO:-0400\r\nEND:DAYLIGHT\r\nBEGIN:STANDARD\r\nDTSTART:20261101T020000\r\nTZOFFSETFROM:-0400\r\nTZOFFSETTO:-0500\r\nEND:STANDARD\r\nBEGIN:DAYLIGHT\r\nDTSTART:20270314T020000\r\nTZOFFSETFROM:-0500\r\nTZOFFSETTO:-0400\r\nEND:DAYLIGHT\r\nBEGIN:STANDARD\r\nDTSTART:20271107T020000\r\nTZOFFSETFROM:-0400\r\nTZOFFSETTO:-0500\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\nBEGIN:VEVENT\r\nUID:evtrsm7egur84pa64mdnoet@cal.pyas.io\r\nSEQUENCE:0\r\nDTSTAMP:20261108T200032Z\r\nDTSTART;TZID=America/New_York:20261110T100000\r\nDTEND;TZID=America/New_York:20261110T110000\r\nSUMMARY:Project kickoff\r\nORGANIZER;CN=Avery Brooks:mailto:calfda58c59801p@cal.pyas.io\r\nATTENDEE;CN=Sam Patel;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:\r\n mailto:sam.patel@example.org\r\nSTATUS:CONFIRMED\r\nTRANSP:OPAQUE\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: # Webhooks ::lead Receive events at your own endpoints through signed, retried and recorded deliveries. Includes replays, test deliveries and a relay for sandbox events. :: ::note The examples use IDs from a sample sandbox. Your sandbox has the same people, calendars and events with its own IDs, which `GET /v2/sandbox` returns. When you're signed in to the dashboard, **Try it** fills in your IDs and sends the request to your sandbox, never to live data. :: --- ::section-heading{id="webhooks-list" tag="GET" label="/v2/webhooks"} List webhook endpoints :: ::row :::column Returns the app's webhook endpoints. A live key returns live endpoints, and a test key returns test endpoints, which receive events from the sandbox. Endpoints created in the dashboard before v2 are included too, and use v1 payloads until you switch them. **In the sandbox:** A test key lists test endpoints, which receive events from the sandbox. Every sandbox includes the sandbox inbox (whk_sbx671d37d5e9995de7b6be), an endpoint hosted by Pyas. :try-it{operation="webhooks.list"} ### Errors - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/webhooks"} ```bash [cURL] curl 'https://api.pyas.io/v2/webhooks' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.webhooks.list() ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": [ { "webhookId": "whk_sbx671d37d5e9995de7b6be", "url": "pyas://sandbox-inbox", "description": "The sandbox inbox, an endpoint hosted by Pyas that receives every sandbox event. To read what it received, call GET /v2/webhooks/deliveries.", "mode": "test", "payloadVersion": "v2", "events": [ "*" ], "triggers": [], "status": "active", "disabledReason": null, "disabledAt": null, "auth": null, "signingSecret": { "last4": "9286" }, "consecutiveFailures": 0, "createdAt": "2026-11-14T20:00:24.351Z", "updatedAt": "2026-11-14T20:00:24.351Z" } ], "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="webhooks-create" tag="POST" label="/v2/webhooks"} Create a webhook endpoint :: ::row :::column Creates a webhook endpoint. Pyas sends each event the endpoint subscribes to as a POST request, signed in the `Pyas-Signature` header with the signing secret returned in this response. Store the secret. You can retrieve it again later with `GET /v2/webhooks/:webhookId/secret`. To check that your endpoint works, send it a test delivery with `POST /v2/webhooks/:webhookId/test`. **In the sandbox:** The response includes the signing secret. Next, send the endpoint a test delivery with `POST /v2/webhooks/{webhookId}/test`. :try-it{operation="webhooks.create"} ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="url" type="string" required} The URL of your endpoint. Must use HTTPS, except for test endpoints, which also accept HTTP. ::::: :::::property{name="events" type="array of string"} The event types to receive. Defaults to `["*"]`, which subscribes to all types, including types added later. At most 50 items. ::::: :::::property{name="description" type="string | null"} ::::: :::::property{name="auth" type="object | null"} A credential Pyas sends with every delivery, either as a custom header or as a bearer token. ::::: :::::property{name="payloadVersion" type="string"} One of `v2`. ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`BILLING_FEATURE_UNAVAILABLE`](/errors/BILLING_FEATURE_UNAVAILABLE) (403): This feature is not included in the current plan. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_LIMIT_REACHED`](/errors/RESOURCE_LIMIT_REACHED) (409): The limit for this resource has been reached. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/webhooks"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/webhooks' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "url": "https://example.com/pyas/webhooks", "events": [ "event.*", "connection.broken" ], "description": "Calendar sync", "auth": { "type": "header", "name": "X-Webhook-Key", "value": "my-shared-secret" } }' ``` ```js [Node.js] const result = await pyas.webhooks.create({ "url": "https://example.com/pyas/webhooks", "events": [ "event.*", "connection.broken" ], "description": "Calendar sync", "auth": { "type": "header", "name": "X-Webhook-Key", "value": "my-shared-secret" } }) ``` :::: ::::code-group{title="Response" label="201 Created"} ```json { "success": true, "data": { "endpoint": { "webhookId": "004af03b-0f03-4e97-91df-852da33048d9", "url": "https://example.com/pyas/webhooks", "description": "Calendar sync", "mode": "test", "payloadVersion": "v2", "events": [ "event.*", "connection.broken" ], "triggers": [], "status": "active", "disabledReason": null, "disabledAt": null, "auth": { "type": "header", "name": "X-Webhook-Key" }, "signingSecret": { "last4": "Wazs" }, "consecutiveFailures": 0, "createdAt": "2026-11-14T20:00:25.699Z", "updatedAt": "2026-11-14T20:00:25.699Z" }, "signingSecret": "whsec_example" }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="webhooks-get" tag="GET" label="/v2/webhooks/{webhookId}"} Get a webhook endpoint :: ::row :::column :try-it{operation="webhooks.get"} ### Path parameters ::::properties :::::property{name="webhookId" type="string" required} ::::: :::: ### Errors - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/webhooks/{webhookId}"} ```bash [cURL] curl 'https://api.pyas.io/v2/webhooks/whk_sbx671d37d5e9995de7b6be' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.webhooks.get({ "webhookId": "whk_sbx671d37d5e9995de7b6be" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "webhookId": "whk_sbx671d37d5e9995de7b6be", "url": "pyas://sandbox-inbox", "description": "The sandbox inbox, an endpoint hosted by Pyas that receives every sandbox event. To read what it received, call GET /v2/webhooks/deliveries.", "mode": "test", "payloadVersion": "v2", "events": [ "*" ], "triggers": [], "status": "active", "disabledReason": null, "disabledAt": null, "auth": null, "signingSecret": { "last4": "9286" }, "consecutiveFailures": 0, "createdAt": "2026-11-14T20:00:24.351Z", "updatedAt": "2026-11-14T20:00:24.351Z" }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="webhooks-update" tag="PATCH" label="/v2/webhooks/{webhookId}"} Update a webhook endpoint :: ::row :::column Updates the endpoint's URL, events, credential or description. You can also disable or re-enable it with `status`. Before re-enabling an endpoint, Pyas sends it a test delivery, and the endpoint stays disabled if that delivery fails. To switch an endpoint created before v2 to v2 payloads, set `payloadVersion` to `"v2"`. After the switch, v1 no longer sends the endpoint anything, and it receives signed, retried v2 deliveries of the event types that its v1 triggers correspond to, unless you set `events`. The first time an endpoint switches, a signing secret is created and returned in this response. :try-it{operation="webhooks.update"} ### Path parameters ::::properties :::::property{name="webhookId" type="string" required} ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="url" type="string"} ::::: :::::property{name="events" type="array of string"} At most 50 items. ::::: :::::property{name="description" type="string | null"} ::::: :::::property{name="auth" type="object | null"} A new credential, or `null` to remove it. ::::: :::::property{name="status" type="string"} One of `active`, `disabled`. ::::: :::::property{name="payloadVersion" type="string"} One of `v1`, `v2`. ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`VALIDATION_ENDPOINT_UNREACHABLE`](/errors/VALIDATION_ENDPOINT_UNREACHABLE) (422): The webhook endpoint did not accept a test delivery. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="PATCH" label="/v2/webhooks/{webhookId}"} ```bash [cURL] curl -X PATCH 'https://api.pyas.io/v2/webhooks/whk_sbx671d37d5e9995de7b6be' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "events": [ "event.*", "calendar.*" ], "description": "Sandbox inbox: events and calendars" }' ``` ```js [Node.js] const result = await pyas.webhooks.update({ "webhookId": "whk_sbx671d37d5e9995de7b6be", "events": [ "event.*", "calendar.*" ], "description": "Sandbox inbox: events and calendars" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "endpoint": { "webhookId": "whk_sbx671d37d5e9995de7b6be", "url": "pyas://sandbox-inbox", "description": "Sandbox inbox: events and calendars", "mode": "test", "payloadVersion": "v2", "events": [ "event.*", "calendar.*" ], "triggers": [], "status": "active", "disabledReason": null, "disabledAt": null, "auth": null, "signingSecret": { "last4": "E2vA" }, "consecutiveFailures": 0, "createdAt": "2026-11-14T20:00:25.476Z", "updatedAt": "2026-11-14T20:00:25.767Z" } }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="webhooks-delete" tag="DELETE" label="/v2/webhooks/{webhookId}"} Delete a webhook endpoint :: ::row :::column Deletes the endpoint. Pyas stops sending deliveries to it, and its deliveries remain available for 30 days. **In the sandbox:** This deletes the sandbox inbox. `POST /v2/sandbox/reset` creates it again. :try-it{operation="webhooks.delete"} ### Path parameters ::::properties :::::property{name="webhookId" type="string" required} ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Errors - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="DELETE" label="/v2/webhooks/{webhookId}"} ```bash [cURL] curl -X DELETE 'https://api.pyas.io/v2/webhooks/whk_sbx671d37d5e9995de7b6be' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.webhooks.delete({ "webhookId": "whk_sbx671d37d5e9995de7b6be" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": null, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="webhooks-deliveries-list" tag="GET" label="/v2/webhooks/deliveries"} List webhook deliveries :: ::row :::column Returns deliveries from the last 30 days, newest first. Each delivery includes every attempt, with its status code, the first 1 KB of the response body, the time taken and any error. Use `status=failed` to list the dead-letter queue, which holds the deliveries that failed after every attempt. :try-it{operation="webhooks.deliveries.list"} ### Query parameters ::::properties :::::property{name="webhookId" type="string"} ::::: :::::property{name="status" type="string"} Returns only deliveries with this status. One of `pending`, `retrying`, `delivered`, `failed`. ::::: :::::property{name="eventType" type="string"} ::::: :::::property{name="eventId" type="string"} ::::: :::::property{name="since" type="string (date-time)"} An ISO 8601 time with a zone. ::::: :::::property{name="limit" type="integer"} The maximum number of results in one page. From 1 to 100. ::::: :::::property{name="cursor" type="string"} The `nextCursor` from the previous page. ::::: :::: ### Errors - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/webhooks/deliveries"} ```bash [cURL] curl 'https://api.pyas.io/v2/webhooks/deliveries?webhookId=whk_sbx671d37d5e9995de7b6be' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.webhooks.deliveries.list({ "webhookId": "whk_sbx671d37d5e9995de7b6be" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": [ { "deliveryId": "del_sbxf61f198fc5d9f0072a38", "webhookId": "whk_sbx671d37d5e9995de7b6be", "eventId": "evt_sbx0b0f2dbfa1198fcb366c", "eventType": "webhook.ping", "accountId": null, "status": "delivered", "attempts": [ { "n": 1, "at": "2026-11-14T20:00:23Z", "httpStatus": 200, "responseBody": "{\"received\":true,\"by\":\"the Pyas sandbox inbox\"}", "responseTimeMs": 0, "error": null } ], "nextAttemptAt": null, "replays": 0, "createdAt": "2026-11-14T20:00:23Z", "completedAt": "2026-11-14T20:00:23Z" } ], "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": { "nextCursor": null } } ``` :::: ::: :: --- ::section-heading{id="webhooks-eventtypes" tag="GET" label="/v2/webhooks/event-types"} List webhook event types :: ::row :::column Returns every event type, a description of what it means, and a sample of the `data` object that a delivery of that type includes. :try-it{operation="webhooks.eventTypes"} ### Errors - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/webhooks/event-types"} ```bash [cURL] curl 'https://api.pyas.io/v2/webhooks/event-types' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.webhooks.eventTypes() ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": [ { "type": "connection.created", "description": "An account was connected for the first time. `data.object` is the account.", "sample": { "object": { "accountId": "acc_sample", "provider": "google", "email": "avery.brooks@example.com", "name": "Avery Brooks", "status": "active", "subjectId": "user_123", "scopes": [ "https://www.googleapis.com/auth/calendar" ], "createdAt": "2026-10-15T20:00:24Z", "updatedAt": "2026-11-14T20:00:24Z" } } }, { "type": "connection.reauthorized", "description": "An account was connected again, after it stopped working or to grant more permissions. `data.object` is the account.", "sample": { "object": { "accountId": "acc_sample", "provider": "google", "email": "avery.brooks@example.com", "name": "Avery Brooks", "status": "active", "subjectId": "user_123", "scopes": [ "https://www.googleapis.com/auth/calendar" ], "createdAt": "2026-10-15T20:00:24Z", "updatedAt": "2026-11-14T20:00:24Z" } } }, { "type": "connection.broken", "description": "An account stopped working because access was revoked or could not be renewed. The user needs to connect it again. `data.object` is the account.", "sample": { "object": { "accountId": "acc_sample", "provider": "google", "email": "avery.brooks@example.com", "name": "Avery Brooks", "status": "inactive", "subjectId": "user_123", "scopes": [ "https://www.googleapis.com/auth/calendar" ], "createdAt": "2026-10-15T20:00:24Z", "updatedAt": "2026-11-14T20:00:24Z" } } }, { "type": "connection.deleted", "description": "An account was deleted from Pyas. `data.object` is the account as it was before it was deleted.", "sample": { "object": { "accountId": "acc_sample", "provider": "google", "email": "avery.brooks@example.com", "name": "Avery Brooks", "status": "active", "subjectId": "user_123", "scopes": [ "https://www.googleapis.com/auth/calendar" ], "createdAt": "2026-10-15T20:00:24Z", "updatedAt": "2026-11-14T20:00:24Z" } } }, { "type": "calendar.created", "description": "A calendar was added to an account. `data.object` holds the calendar ID.", "sample": { "object": { "id": "team@group.calendar.google.com" } } }, { "type": "calendar.updated", "description": "A calendar was updated, for example its name, color or time zone. `data.object` holds the calendar ID. Read the calendar to get its new details.", "sample": { "object": { "id": "team@group.calendar.google.com" } } }, { "type": "calendar.deleted", "description": "A calendar was removed from an account. `data.object` holds the calendar ID.", "sample": { "object": { "id": "team@group.calendar.google.com" } } }, { "type": "event.created", "description": "An event was created. A new recurring series sends one webhook for the series. `data.object` holds the event ID and calendar ID. Read the event to get its details.", "sample": { "object": { "id": "sample1a2b3c4d5e6f7g8h9i", "calendarId": "primary", "seriesId": null, "isSeries": false } } }, { "type": "event.updated", "description": "An event was updated. An update to a whole series sends one webhook for the series. `data.object` holds the event ID and calendar ID. Read the event to get its new details.", "sample": { "object": { "id": "sample1a2b3c4d5e6f7g8h9i", "calendarId": "primary", "seriesId": null, "isSeries": false } } }, { "type": "event.deleted", "description": "An event was deleted or canceled. `data.object` holds the event ID and calendar ID.", "sample": { "object": { "id": "sample1a2b3c4d5e6f7g8h9i", "calendarId": "primary", "seriesId": null, "isSeries": false } } }, { "type": "invitation.replied", "description": "An attendee responded to an invitation sent from a virtual calendar. `data.object` holds the event ID and calendar ID, and `data.reply` is the response.", "sample": { "object": { "id": "sample1a2b3c4d5e6f7g8h9i", "calendarId": "primary", "seriesId": null, "isSeries": false }, "reply": { "email": "sam.patel@example.org", "response": "tentative", "comment": "Running late from another meeting.", "proposedTime": null } } }, { "type": "meeting.updated", "description": "A Zoom meeting that Pyas created for an event was updated in Zoom. `data.object` is the meeting and the event it belongs to, and `data.previous` contains the previous values. Pyas doesn't update the calendar event. Update the event yourself if you want it to match.", "sample": { "object": { "meetingId": "84512345678", "uuid": "SnJ2bYn7QnWqUj3sSvKQ5A==", "topic": "Customer onboarding", "startsAt": "2026-11-15T21:00:00Z", "durationMinutes": 60, "timezone": "America/New_York", "eventId": "sample1a2b3c4d5e6f7g8h9i", "calendarRef": "primary", "accountId": "acc_sample", "zoomAccountId": "acc_zoom_sample" }, "previous": { "startsAt": "2026-11-15T20:00:00Z" } } }, { "type": "meeting.deleted", "description": "A Zoom meeting that Pyas created for an event was deleted in Zoom, so the event's join link no longer works. `data.object` is the meeting and its event.", "sample": { "object": { "meetingId": "84512345678", "uuid": "SnJ2bYn7QnWqUj3sSvKQ5A==", "topic": "Customer onboarding", "startsAt": "2026-11-15T21:00:00Z", "durationMinutes": 60, "timezone": "America/New_York", "eventId": "sample1a2b3c4d5e6f7g8h9i", "calendarRef": "primary", "accountId": "acc_sample", "zoomAccountId": "acc_zoom_sample" } } }, { "type": "meeting.started", "description": "A Zoom meeting that Pyas created for an event started. `data.object` is the meeting and its event.", "sample": { "object": { "meetingId": "84512345678", "uuid": "SnJ2bYn7QnWqUj3sSvKQ5A==", "topic": "Customer onboarding", "startsAt": "2026-11-15T21:00:00Z", "durationMinutes": 60, "timezone": "America/New_York", "eventId": "sample1a2b3c4d5e6f7g8h9i", "calendarRef": "primary", "accountId": "acc_sample", "zoomAccountId": "acc_zoom_sample", "startedAt": "2026-11-14T20:00:24Z" } } }, { "type": "meeting.ended", "description": "A Zoom meeting that Pyas created for an event ended. `data.object` is the meeting and its event, with `startedAt` and `endedAt`.", "sample": { "object": { "meetingId": "84512345678", "uuid": "SnJ2bYn7QnWqUj3sSvKQ5A==", "topic": "Customer onboarding", "startsAt": "2026-11-15T21:00:00Z", "durationMinutes": 60, "timezone": "America/New_York", "eventId": "sample1a2b3c4d5e6f7g8h9i", "calendarRef": "primary", "accountId": "acc_sample", "zoomAccountId": "acc_zoom_sample", "startedAt": "2026-11-14T19:05:24Z", "endedAt": "2026-11-14T20:00:24Z" } } }, { "type": "meeting.participant_joined", "description": "A participant joined a Zoom meeting that Pyas created for an event. `data.participant` identifies the participant.", "sample": { "object": { "meetingId": "84512345678", "uuid": "SnJ2bYn7QnWqUj3sSvKQ5A==", "topic": "Customer onboarding", "startsAt": "2026-11-15T21:00:00Z", "durationMinutes": 60, "timezone": "America/New_York", "eventId": "sample1a2b3c4d5e6f7g8h9i", "calendarRef": "primary", "accountId": "acc_sample", "zoomAccountId": "acc_zoom_sample" }, "participant": { "name": "Sam Patel", "email": "sam.patel@example.org", "joinedAt": "2026-11-14T20:00:24Z" } } }, { "type": "meeting.participant_left", "description": "A participant left a Zoom meeting that Pyas created for an event. `data.participant` identifies the participant.", "sample": { "object": { "meetingId": "84512345678", "uuid": "SnJ2bYn7QnWqUj3sSvKQ5A==", "topic": "Customer onboarding", "startsAt": "2026-11-15T21:00:00Z", "durationMinutes": 60, "timezone": "America/New_York", "eventId": "sample1a2b3c4d5e6f7g8h9i", "calendarRef": "primary", "accountId": "acc_sample", "zoomAccountId": "acc_zoom_sample" }, "participant": { "name": "Sam Patel", "email": "sam.patel@example.org", "leftAt": "2026-11-14T20:00:24Z" } } }, { "type": "recording.ready", "description": "The cloud recording of a Zoom meeting that Pyas created for an event is ready. `data.object` lists the recording files with their type, size, start and end times, and share link.", "sample": { "object": { "meetingId": "84512345678", "uuid": "SnJ2bYn7QnWqUj3sSvKQ5A==", "topic": "Customer onboarding", "startsAt": "2026-11-15T21:00:00Z", "durationMinutes": 60, "timezone": "America/New_York", "eventId": "sample1a2b3c4d5e6f7g8h9i", "calendarRef": "primary", "accountId": "acc_sample", "zoomAccountId": "acc_zoom_sample", "shareUrl": "https://zoom.us/rec/share/sample", "files": [ { "id": "rec-1", "type": "shared_screen_with_speaker_view", "fileType": "MP4", "sizeBytes": 48213002, "startedAt": "2026-11-14T19:05:24Z", "endedAt": "2026-11-14T20:00:24Z", "playUrl": "https://zoom.us/rec/play/sample" }, { "id": "rec-2", "type": "audio_transcript", "fileType": "TRANSCRIPT", "sizeBytes": 18204, "startedAt": "2026-11-14T19:05:24Z", "endedAt": "2026-11-14T20:00:24Z", "playUrl": null } ] } } }, { "type": "api_key.expiring", "description": "An API key expires soon. Sent a week before the key's expiry date, or an hour before a rotated key's grace period ends. `data.object` is the key, without its secret value.", "sample": { "object": { "keyId": "key_sample", "name": "production", "mode": "live", "expiresAt": "2026-11-14T21:00:24Z", "rotatedTo": "key_sample2" } } }, { "type": "api_key.revoked", "description": "A rotated API key's grace period ended, and the key no longer works. `data.object` is the key.", "sample": { "object": { "keyId": "key_sample", "name": "production", "mode": "live", "expiresAt": "2026-11-14T20:00:24Z", "rotatedTo": "key_sample2" } } }, { "type": "webhook.ping", "description": "A test delivery. Pyas sends it when you request one and when an endpoint is enabled again.", "sample": { "object": { "message": "A test delivery from Pyas." } } } ], "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="webhooks-events-list" tag="GET" label="/v2/webhooks/events"} List webhook events :: ::row :::column Returns events from the last 30 days, newest first, whether or not an endpoint received them. Use it to catch up after downtime or to check what happened. A test key returns the sandbox's events. :try-it{operation="webhooks.events.list"} ### Query parameters ::::properties :::::property{name="type" type="string"} An event type, or a family such as `event.*`. ::::: :::::property{name="accountId" type="string"} ::::: :::::property{name="since" type="string (date-time)"} Returns only events created at or after this time. ::::: :::::property{name="limit" type="integer"} The maximum number of results in one page. From 1 to 100. ::::: :::::property{name="cursor" type="string"} The `nextCursor` from the previous page. ::::: :::: ### Errors - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/webhooks/events"} ```bash [cURL] curl 'https://api.pyas.io/v2/webhooks/events?type=webhook.ping' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.webhooks.events.list({ "type": "webhook.ping" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": [ { "eventId": "evt_sbx0b0f2dbfa1198fcb366c", "type": "webhook.ping", "accountId": null, "subjectId": null, "provider": null, "createdAt": "2026-11-14T20:00:23Z", "payload": { "id": "evt_sbx0b0f2dbfa1198fcb366c", "type": "webhook.ping", "apiVersion": "2", "createdAt": "2026-11-14T20:00:23Z", "livemode": false, "appId": "5c1e9a47-2d3b-4f86-a0e9-7b4c2d18f365", "accountId": null, "subjectId": null, "provider": null, "data": { "object": { "message": "The sandbox inbox is ready." } }, "request": null } } ], "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": { "nextCursor": null } } ``` :::: ::: :: --- ::section-heading{id="webhooks-listen" tag="GET" label="/v2/webhooks/listen"} Stream sandbox webhook events :: ::row :::column Opens a Server-Sent Events stream of the sandbox's events as they happen, so you can receive webhooks on a machine with no public endpoint. Requires a test key. The stream starts with an `event: ready` message that includes a signing secret created for this stream. Each `event: delivery` message that follows includes a body and the same headers as a real delivery, signed with that secret. POST each one to your local handler to run it exactly as in production. The `pyas webhooks listen --forward` command does this for you. A stream stays open for 1 hour, and an app can have at most 5 streams open at a time. **In the sandbox:** The stream stays open. In a terminal, run `curl -N -H "x-api-key: " https://api.pyas.io/v2/webhooks/listen`, then create or update an event in the sandbox. :try-it{operation="webhooks.listen"} ### Query parameters ::::properties :::::property{name="events" type="string"} A comma-separated list of event types to relay. Accepts `*` (the default) or a family such as `event.*`. ::::: :::: ### Errors - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_SANDBOX_MISMATCH`](/errors/AUTH_SANDBOX_MISMATCH) (403): A test key was used with live data, or a live key was used with sandbox data. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_LIMIT_REACHED`](/errors/RESOURCE_LIMIT_REACHED) (409): The limit for this resource has been reached. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/webhooks/listen"} ```bash [cURL] curl 'https://api.pyas.io/v2/webhooks/listen?events=event.*%2Cconnection.*' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.webhooks.listen({ "events": "event.*,connection.*" }) ``` :::: ::::code-group{title="Response"} ```text [Server-sent events] event: ready data: {...} event: delivery data: {...} ``` :::: ::: :: --- ::section-heading{id="webhooks-replay" tag="POST" label="/v2/webhooks/{webhookId}/replay"} Replay failed webhook deliveries :: ::row :::column Replays the deliveries to this endpoint that failed after every attempt, starting from the given time. Each request replays at most 1000 deliveries. :try-it{operation="webhooks.replay"} ### Path parameters ::::properties :::::property{name="webhookId" type="string" required} ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="since" type="string" required} An ISO 8601 time. Replays failed deliveries created at or after this time. ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/webhooks/{webhookId}/replay"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/webhooks/whk_sbx671d37d5e9995de7b6be/replay' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "since": "2026-11-13T00:00:00Z" }' ``` ```js [Node.js] const result = await pyas.webhooks.replay({ "webhookId": "whk_sbx671d37d5e9995de7b6be", "since": "2026-11-13T00:00:00Z" }) ``` :::: ::::code-group{title="Response" label="202 Accepted"} ```json { "success": true, "data": { "replayed": 0 }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="webhooks-secret-get" tag="GET" label="/v2/webhooks/{webhookId}/secret"} Retrieve the signing secret :: ::row :::column Returns the secret that deliveries are signed with and, after a rotation, when the previous secret stops signing. Requires a key with the write scope. :try-it{operation="webhooks.secret.get"} ### Path parameters ::::properties :::::property{name="webhookId" type="string" required} ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/webhooks/{webhookId}/secret"} ```bash [cURL] curl 'https://api.pyas.io/v2/webhooks/whk_sbx671d37d5e9995de7b6be/secret' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.webhooks.secret.get({ "webhookId": "whk_sbx671d37d5e9995de7b6be" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "signingSecret": "whsec_example", "previous": null }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="webhooks-test" tag="POST" label="/v2/webhooks/{webhookId}/test"} Send a test delivery :: ::row :::column Sends a sample of any event type to the endpoint immediately, signed like a real delivery, and returns the result. The result includes your endpoint's status code, its response body, the time it took, and exactly what was sent, with your own credential hidden. Test deliveries are not retried, don't count against the endpoint, are marked `"test": true`, and are left out of the event list. You can test an endpoint while it is disabled. **In the sandbox:** You can send any event type. Your handler receives a realistic sample, signed like a real delivery, and the response shows how your endpoint responded. :try-it{operation="webhooks.test"} ### Path parameters ::::properties :::::property{name="webhookId" type="string" required} ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="type" type="string"} The event type to send a sample of. Defaults to `webhook.ping`. ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/webhooks/{webhookId}/test"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/webhooks/whk_sbx671d37d5e9995de7b6be/test' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "type": "event.updated" }' ``` ```js [Node.js] const result = await pyas.webhooks.test({ "webhookId": "whk_sbx671d37d5e9995de7b6be", "type": "event.updated" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "delivered": true, "delivery": { "deliveryId": "del_v2dH4f5ag8CB77OT1WBx", "webhookId": "whk_sbx671d37d5e9995de7b6be", "eventId": "evt_7yChTM94C793nCcM9697", "eventType": "event.updated", "accountId": null, "status": "delivered", "attempts": [ { "n": 1, "at": "2026-11-14T20:00:25Z", "httpStatus": 200, "responseBody": "{\"received\":true,\"by\":\"the Pyas sandbox inbox\"}", "responseTimeMs": 0, "error": null } ], "nextAttemptAt": null, "replays": 0, "createdAt": "2026-11-14T20:00:25Z", "completedAt": "2026-11-14T20:00:25Z" }, "sent": { "url": "pyas://sandbox-inbox", "headers": { "Content-Type": "application/json", "User-Agent": "Pyas-Webhooks/2", "Pyas-Event-Id": "evt_7yChTM94C793nCcM9697", "Pyas-Event-Type": "event.updated", "Pyas-Delivery-Id": "del_v2dH4f5ag8CB77OT1WBx", "Pyas-Timestamp": "1794168025", "Pyas-Signature": "t=1794168025,v1=3c070b14da751b2be2c4d1a452b6e588894caeb651fbacdd8650f467dded1091" }, "body": "{\"id\":\"evt_7yChTM94C793nCcM9697\",\"type\":\"event.updated\",\"apiVersion\":\"2\",\"createdAt\":\"2026-11-14T20:00:25Z\",\"livemode\":false,\"appId\":\"5c1e9a47-2d3b-4f86-a0e9-7b4c2d18f365\",\"accountId\":null,\"subjectId\":null,\"provider\":null,\"data\":{\"object\":{\"id\":\"sample1a2b3c4d5e6f7g8h9i\",\"calendarId\":\"primary\",\"seriesId\":null,\"isSeries\":false}},\"request\":null,\"test\":true}" } }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="webhooks-deliveries-get" tag="GET" label="/v2/webhooks/deliveries/{deliveryId}"} Get a webhook delivery :: ::row :::column Returns one delivery, including every attempt. :try-it{operation="webhooks.deliveries.get"} ### Path parameters ::::properties :::::property{name="deliveryId" type="string" required} ::::: :::: ### Errors - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/webhooks/deliveries/{deliveryId}"} ```bash [cURL] curl 'https://api.pyas.io/v2/webhooks/deliveries/del_sbxf61f198fc5d9f0072a38' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.webhooks.deliveries.get({ "deliveryId": "del_sbxf61f198fc5d9f0072a38" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "deliveryId": "del_sbxf61f198fc5d9f0072a38", "webhookId": "whk_sbx671d37d5e9995de7b6be", "eventId": "evt_sbx0b0f2dbfa1198fcb366c", "eventType": "webhook.ping", "accountId": null, "status": "delivered", "attempts": [ { "n": 1, "at": "2026-11-14T20:00:23Z", "httpStatus": 200, "responseBody": "{\"received\":true,\"by\":\"the Pyas sandbox inbox\"}", "responseTimeMs": 0, "error": null } ], "nextAttemptAt": null, "replays": 0, "createdAt": "2026-11-14T20:00:23Z", "completedAt": "2026-11-14T20:00:23Z" }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="webhooks-events-get" tag="GET" label="/v2/webhooks/events/{eventId}"} Get a webhook event :: ::row :::column Returns one event and its payload, exactly as delivered. :try-it{operation="webhooks.events.get"} ### Path parameters ::::properties :::::property{name="eventId" type="string" required} ::::: :::: ### Errors - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/webhooks/events/{eventId}"} ```bash [cURL] curl 'https://api.pyas.io/v2/webhooks/events/evt_sbx0b0f2dbfa1198fcb366c' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.webhooks.events.get({ "eventId": "evt_sbx0b0f2dbfa1198fcb366c" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "eventId": "evt_sbx0b0f2dbfa1198fcb366c", "type": "webhook.ping", "accountId": null, "subjectId": null, "provider": null, "createdAt": "2026-11-14T20:00:23Z", "payload": { "id": "evt_sbx0b0f2dbfa1198fcb366c", "type": "webhook.ping", "apiVersion": "2", "createdAt": "2026-11-14T20:00:23Z", "livemode": false, "appId": "5c1e9a47-2d3b-4f86-a0e9-7b4c2d18f365", "accountId": null, "subjectId": null, "provider": null, "data": { "object": { "message": "The sandbox inbox is ready." } }, "request": null } }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="webhooks-secret-rotate" tag="POST" label="/v2/webhooks/{webhookId}/secret/rotate"} Rotate the signing secret :: ::row :::column Creates a new signing secret. The previous secret keeps signing deliveries alongside the new one for `graceHours` (default 24, at most 168), so you can update your endpoint without rejecting any deliveries. :try-it{operation="webhooks.secret.rotate"} ### Path parameters ::::properties :::::property{name="webhookId" type="string" required} ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="graceHours" type="integer"} From 0 to 168. ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/webhooks/{webhookId}/secret/rotate"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/webhooks/whk_sbx671d37d5e9995de7b6be/secret/rotate' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "graceHours": 24 }' ``` ```js [Node.js] const result = await pyas.webhooks.secret.rotate({ "webhookId": "whk_sbx671d37d5e9995de7b6be", "graceHours": 24 }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "signingSecret": "whsec_example", "previous": { "expiresAt": "2026-11-15T20:00:25.724Z" } }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="webhooks-deliveries-replay" tag="POST" label="/v2/webhooks/deliveries/{deliveryId}/replay"} Replay a webhook delivery :: ::row :::column Replays the delivery with a new series of attempts, sending the same event with the same body and `Pyas-Event-Id`. You can also replay a delivery that succeeded, for example if your endpoint lost it. :try-it{operation="webhooks.deliveries.replay"} ### Path parameters ::::properties :::::property{name="deliveryId" type="string" required} ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Errors - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`RESOURCE_CONFLICT`](/errors/RESOURCE_CONFLICT) (409): The resource was modified or already exists. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/webhooks/deliveries/{deliveryId}/replay"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/webhooks/deliveries/del_sbxf61f198fc5d9f0072a38/replay' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.webhooks.deliveries.replay({ "deliveryId": "del_sbxf61f198fc5d9f0072a38" }) ``` :::: ::::code-group{title="Response" label="202 Accepted"} ```json { "success": true, "data": { "deliveryId": "del_sbxf61f198fc5d9f0072a38", "webhookId": "whk_sbx671d37d5e9995de7b6be", "eventId": "evt_sbx0b0f2dbfa1198fcb366c", "eventType": "webhook.ping", "accountId": null, "status": "pending", "attempts": [ { "n": 1, "at": "2026-11-14T20:00:24Z", "httpStatus": 200, "responseBody": "{\"received\":true,\"by\":\"the Pyas sandbox inbox\"}", "responseTimeMs": 0, "error": null } ], "nextAttemptAt": "2026-11-14T20:00:25Z", "replays": 1, "createdAt": "2026-11-14T20:00:24Z", "completedAt": null }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: # Metadata ::lead Import the extended properties stored at the provider into Pyas metadata. :: ::note The examples use IDs from a sample sandbox. Your sandbox has the same people, calendars and events with its own IDs, which `GET /v2/sandbox` returns. When you're signed in to the dashboard, **Try it** fills in your IDs and sends the request to your sandbox, never to live data. :: --- ::section-heading{id="metadata-import" tag="POST" label="/v2/metadata/import"} Import extended properties into metadata :: ::row :::column Copies the provider's extended properties from events on the selected calendars into the events' metadata. The import runs in the background and doesn't modify the calendar. With `dryRun: true`, the API immediately returns a report on a sample, showing which keys exist and on how many events, and writes nothing. **In the sandbox:** Copies the extended properties that another app wrote on Google and Outlook events into metadata. A dry run reports what would be copied. For Outlook, set `microsoft.properties` to the IDs of the properties to read. :try-it{operation="metadata.import"} ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="accountIds" type="array of string"} The accounts to import from. Send one of `accountIds`, `subjectId` or `allAccounts`. At most 100 items. ::::: :::::property{name="subjectId" type="string"} Imports from every account with this `subjectId`. ::::: :::::property{name="allAccounts" type="true"} Imports from every account in the app. ::::: :::::property{name="calendarRefs" type="array of string"} The calendars to read. Defaults to `primary`. At most 20 items. ::::: :::::property{name="from" type="string (date-time)"} An ISO 8601 time with a zone. ::::: :::::property{name="to" type="string (date-time)"} An ISO 8601 time with a zone. ::::: :::::property{name="google" type="object"} ::::: :::::property{name="google.scope" type="string"} Which extended properties to read. Defaults to `both`. One of `private`, `shared`, `both`. ::::: :::::property{name="google.keys" type="array of string"} The keys to import. Defaults to every key. At most 50 items. ::::: :::::property{name="microsoft" type="object"} ::::: :::::property{name="microsoft.properties" type="array of string | object" required} The properties to read, by ID, of any type: `String {00020329-0000-0000-c000-000000000046} Name orderId`, `IntegerArray {...} Name counts` (imported as a list), or a numbered one such as `Integer 0x8012`. A named property imports under its name. To choose the key, send `{ "id": "...", "key": "count" }`, which a numbered property needs. Outlook can read these properties only by ID. At most 10 items. ::::: :::::property{name="overwrite" type="boolean"} Whether to replace metadata already set through v2. Defaults to `false`, which keeps the existing metadata. ::::: :::::property{name="convert" type="string"} With `auto`, values such as "42" and "true" are converted to a number and a boolean. One of `none`, `auto`. ::::: :::::property{name="dryRun" type="boolean"} Returns a report on a sample of recent events without writing any metadata. ::::: :::::property{name="sampleSize" type="integer"} For a dry run, the number of events to read per calendar. Defaults to 200. From 1 to 1000. ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/metadata/import"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/metadata/import' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "accountIds": [ "sbx_fda58c5980_google" ], "dryRun": true }' ``` ```js [Node.js] const result = await pyas.metadata.import({ "accountIds": [ "sbx_fda58c5980_google" ], "dryRun": true }) ``` :::: ::::code-group{title="Response" label="202 Accepted"} ```json { "success": true, "data": { "importId": "mi_2387a8fe491044d8ad779768cee4a8f0", "status": "queued", "request": { "accounts": { "accountIds": [ "sbx_fda58c5980_google" ] }, "calendarRefs": [ "primary" ], "google": { "scope": "both" }, "overwrite": false, "convert": "none" }, "progress": { "calendars": 0, "finished": 0 }, "counts": { "scanned": 0, "copied": 0, "unchanged": 0, "skipped": 0 }, "skippedKeys": [], "skipped": [], "errors": [], "createdAt": "2026-11-14T20:00:32.982Z", "startedAt": null, "finishedAt": null }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="metadata-imports-list" tag="GET" label="/v2/metadata/imports"} List metadata imports :: ::row :::column :try-it{operation="metadata.imports.list"} ### Query parameters ::::properties :::::property{name="limit" type="integer"} The number of imports to return, newest first. Defaults to 20. From 1 to 100. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/metadata/imports"} ```bash [cURL] curl 'https://api.pyas.io/v2/metadata/imports' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.metadata.imports.list() ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": [], "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="metadata-imports-get" tag="GET" label="/v2/metadata/imports/{importId}"} Get a metadata import :: ::row :::column **In the sandbox:** Use the `importId` returned by `POST /v2/metadata/import`. :try-it{operation="metadata.imports.get"} ### Path parameters ::::properties :::::property{name="importId" type="string" required} ::::: :::: ### Errors - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/metadata/imports/{importId}"} ```bash [cURL] curl 'https://api.pyas.io/v2/metadata/imports/mimp_from_the_import' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.metadata.imports.get({ "importId": "mimp_from_the_import" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "importId": "mi_2387a8fe491044d8ad779768cee4a8f0", "status": "queued", "request": { "accounts": { "accountIds": [ "sbx_fda58c5980_google" ] }, "calendarRefs": [ "primary" ], "google": { "scope": "both" }, "overwrite": false, "convert": "none" }, "progress": { "calendars": 0, "finished": 0 }, "counts": { "scanned": 0, "copied": 0, "unchanged": 0, "skipped": 0 }, "skippedKeys": [], "skipped": [], "errors": [], "createdAt": "2026-11-14T20:00:32.982Z", "startedAt": null, "finishedAt": null }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="metadata-imports-cancel" tag="DELETE" label="/v2/metadata/imports/{importId}"} Cancel a metadata import :: ::row :::column **In the sandbox:** Use the `importId` returned by `POST /v2/metadata/import`. :try-it{operation="metadata.imports.cancel"} ### Path parameters ::::properties :::::property{name="importId" type="string" required} ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Errors - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`RESOURCE_CONFLICT`](/errors/RESOURCE_CONFLICT) (409): The resource was modified or already exists. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="DELETE" label="/v2/metadata/imports/{importId}"} ```bash [cURL] curl -X DELETE 'https://api.pyas.io/v2/metadata/imports/mimp_from_the_import' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.metadata.imports.cancel({ "importId": "mimp_from_the_import" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "importId": "mi_2387a8fe491044d8ad779768cee4a8f0", "status": "cancelled", "request": { "accounts": { "accountIds": [ "sbx_fda58c5980_google" ] }, "calendarRefs": [ "primary" ], "google": { "scope": "both" }, "overwrite": false, "convert": "none" }, "progress": { "calendars": 0, "finished": 0 }, "counts": { "scanned": 0, "copied": 0, "unchanged": 0, "skipped": 0 }, "skippedKeys": [], "skipped": [], "errors": [], "createdAt": "2026-11-14T20:00:32.982Z", "startedAt": null, "finishedAt": "2026-11-14T20:00:32.994Z" }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: # Keys ::lead List API keys, and rotate a key with a grace period during which both the old and new keys work. :: ::note The examples use IDs from a sample sandbox. Your sandbox has the same people, calendars and events with its own IDs, which `GET /v2/sandbox` returns. When you're signed in to the dashboard, **Try it** fills in your IDs and sends the request to your sandbox, never to live data. :: --- ::section-heading{id="keys-list" tag="GET" label="/v2/keys"} List API keys :: ::row :::column Returns the app's keys, with their status and, for a rotated key, when it stops working. A live key lists live keys, and a test key lists test keys. Key values are never returned. :try-it{operation="keys.list"} ### Errors - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/keys"} ```bash [cURL] curl 'https://api.pyas.io/v2/keys' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.keys.list() ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": [], "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="keys-get" tag="GET" label="/v2/keys/{keyId}"} Get an API key :: ::row :::column **In the sandbox:** `current` is the key that sent the request. The API reference sends requests with a key created for the docs, which isn't one of your app's keys. With your own test key, this request returns that key. :try-it{operation="keys.get"} ### Path parameters ::::properties :::::property{name="keyId" type="string" required} The key's ID, or `current` for the key that sent the request. ::::: :::: ### Errors - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/keys/{keyId}"} ```bash [cURL] curl 'https://api.pyas.io/v2/keys/current' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.keys.get({ "keyId": "current" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "keyId": "3f7c2a91-5d4e-4b8a-9c6f-2e1d0b7a8c53", "name": "Production", "mode": "live", "scopes": [ "read", "write" ], "status": "active", "last4": null, "expiresAt": null, "graceEndsAt": null, "rotatedFrom": null, "rotatedTo": null, "createdAt": "2026-11-14T20:00:31Z", "seenInBrowser": null }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="keys-rotate" tag="POST" label="/v2/keys/{keyId}/rotate"} Rotate an API key :: ::row :::column Creates a new key with the same name, scopes and mode, and returns its value once in this response. A key can rotate only itself, so send this request with the key that you want to rotate, and use `current` as the key ID. To rotate a different key, use the dashboard. The old key gets a grace period during which both keys work (default 24 hours, at most 168), so you can deploy the new key without any failed requests. When the grace period ends, the old key stops working. Pyas sends an `api_key.expiring` webhook 1 hour before the grace period ends, and an `api_key.revoked` webhook when it ends. A key can be rotated only once. **In the sandbox:** Rotates the key that sent the request. The API reference sends requests with a key created for the docs, which can't be rotated. With your own key, the response includes the new key (returned only once), and the old key keeps working for 24 more hours. :try-it{operation="keys.rotate"} ### Path parameters ::::properties :::::property{name="keyId" type="string" required} The key's ID, or `current` for the key that sent the request. ::::: :::: ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="graceHours" type="integer"} The number of hours that the old key keeps working. `0` stops the old key immediately. From 0 to 168. ::::: :::::property{name="expiresInDays" type="integer"} The number of days until the new key expires. Defaults to the old key's lifetime, or no expiration if the old key never expired. From 1 to 3650. ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`RESOURCE_CONFLICT`](/errors/RESOURCE_CONFLICT) (409): The resource was modified or already exists. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/keys/{keyId}/rotate"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/keys/current/rotate' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "graceHours": 24 }' ``` ```js [Node.js] const result = await pyas.keys.rotate({ "keyId": "current", "graceHours": 24 }) ``` :::: ::::code-group{title="Response" label="201 Created"} ```json { "success": true, "data": { "key": "pyas_sk_live_example", "newKey": { "keyId": "2f660cba-c95f-4314-9466-b494c83efdae", "name": "Production", "mode": "live", "scopes": [ "read", "write" ], "status": "active", "last4": "NzNh", "expiresAt": null, "graceEndsAt": null, "rotatedFrom": "3f7c2a91-5d4e-4b8a-9c6f-2e1d0b7a8c53", "rotatedTo": null, "createdAt": "2026-11-14T20:00:32Z", "seenInBrowser": null }, "previousKey": { "keyId": "3f7c2a91-5d4e-4b8a-9c6f-2e1d0b7a8c53", "name": "Production", "mode": "live", "scopes": [ "read", "write" ], "status": "rotating", "last4": null, "expiresAt": "2026-11-15T20:00:32Z", "graceEndsAt": "2026-11-15T20:00:32Z", "rotatedFrom": null, "rotatedTo": "2f660cba-c95f-4314-9466-b494c83efdae", "createdAt": "2026-11-14T20:00:31Z", "seenInBrowser": null } }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: # Sandbox ::lead The sandbox environment that test keys work in. :: ::note The examples use IDs from a sample sandbox. Your sandbox has the same people, calendars and events with its own IDs, which `GET /v2/sandbox` returns. When you're signed in to the dashboard, **Try it** fills in your IDs and sends the request to your sandbox, never to live data. :: --- ::section-heading{id="sandbox-get" tag="GET" label="/v2/sandbox"} Get the sandbox :: ::row :::column Returns your app's sandbox. The sandbox works only with a test key and is created on its first request. It includes virtual accounts with sample calendars and events, and simulated Google, Outlook and Zoom accounts that respond as those providers do without contacting them. :try-it{operation="sandbox.get"} ### Errors - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_SANDBOX_MISMATCH`](/errors/AUTH_SANDBOX_MISMATCH) (403): A test key was used with live data, or a live key was used with sandbox data. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/sandbox"} ```bash [cURL] curl 'https://api.pyas.io/v2/sandbox' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.sandbox.get() ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "seededAt": "2026-11-14T20:00:23.534Z", "accounts": [ { "accountId": "sbx_fda58c5980_1", "provider": "pyas", "name": "Avery Brooks", "subjectId": "sandbox_user_1" }, { "accountId": "sbx_fda58c5980_2", "provider": "pyas", "name": "Sam Patel", "subjectId": "sandbox_user_2" }, { "accountId": "sbx_fda58c5980_3", "provider": "pyas", "name": "Yuki Tanaka", "subjectId": "sandbox_user_3" }, { "accountId": "sbx_fda58c5980_4", "provider": "pyas", "name": "Jordan Lee", "subjectId": "sandbox_user_4" }, { "accountId": "sbx_fda58c5980_5", "provider": "pyas", "name": "Riley Chen", "subjectId": "sandbox_user_5" }, { "accountId": "sbx_fda58c5980_google", "provider": "google", "name": "Avery Brooks", "subjectId": "sandbox_user_1" }, { "accountId": "sbx_fda58c5980_outlook", "provider": "microsoft", "name": "Sam Patel", "subjectId": "sandbox_user_2" }, { "accountId": "sbx_fda58c5980_zoom", "provider": "zoom", "name": "Avery Brooks", "subjectId": "sandbox_user_1" } ], "examples": { "account.avery": "sbx_fda58c5980_1", "subject.avery": "sandbox_user_1", "calendar.avery.primary": "calfda58c59801p", "calendar.avery.work": "calfda58c59801w", "event.avery.standup": "sb559d0de42dffb4f183c156f8", "event.avery.sprint": "sba4ff5f08df3d4dcbddca25a3", "event.avery.oneonone": "sba938ba8604d9bdff564147c2", "event.avery.productsync": "sbf60c8ae6a9f17c1e3b6cc2b8", "event.avery.design": "sbff8ee84b7939a1d7a4943ff6", "event.avery.customer": "sb0c940c95b43e44fcf6650886", "event.avery.lunch": "sbce3d2d42c2a4d690a9860fbb", "event.avery.dentist": "sb03fe318b8d28506fffad9254", "event.avery.focus": "sbf36d93862d5bc4f8bca3d967", "event.avery.talk": "sb3dc0420b772f0cc723098085", "event.avery.offsite": "sb381bb09d8b9512fdc96458a7", "event.avery.holiday": "sb0f18ff67cf75e9b0f2791f41", "event.avery.quarterly": "sbf031ba171ceac2ab4e014cb1", "event.avery.board": "sb0ee767b6db44fa6f8a796921", "event.avery.release": "sb021ac6e492b8cfcd2a449b94", "event.avery.oncall": "sb2e91a5da5a1fb2493ab0bae5", "event.avery.retro": "sb1b5d95184e2d895435bad820", "event.avery.hiring": "sb4e9d57c17109dc8e28387271", "account.sam": "sbx_fda58c5980_2", "subject.sam": "sandbox_user_2", "calendar.sam.primary": "calfda58c59802p", "calendar.sam.work": "calfda58c59802w", "event.sam.standup": "sbcbc04ba3fe4ddd1f0c0c8821", "event.sam.sprint": "sb07d8b89df0354beb287ab688", "event.sam.oneonone": "sb47a561a28f14bae33d187d98", "event.sam.productsync": "sb15c6c1a7e2f87335fbd2f837", "event.sam.design": "sb0fbdf5e66d7be472d5499373", "event.sam.customer": "sba3778c3cc1ea7e9b2123810f", "event.sam.lunch": "sb0ed0f6f377b2997f3de12ffa", "event.sam.dentist": "sbfdeea6fbfc9016ec8f12bfc9", "event.sam.focus": "sbcddf5da647f2526dd7d990f7", "event.sam.talk": "sbf919294b2e72fec55cd113cc", "event.sam.offsite": "sb2cac9acc3e10edb7242a575d", "event.sam.holiday": "sbe4a0e1fa4ea75e7197c3ba17", "event.sam.quarterly": "sb866914b50adea252ca4c8565", "event.sam.board": "sbc47c845e6b945be417d3062f", "event.sam.release": "sb1379632f67e8d64783a8ac00", "event.sam.oncall": "sb4dc88ff07bf33ca1ed2a029a", "event.sam.retro": "sb71ae3ab164710419d93b4b4a", "event.sam.hiring": "sb5d138acbe172b7921ab1dcb0", "account.yuki": "sbx_fda58c5980_3", "subject.yuki": "sandbox_user_3", "calendar.yuki.primary": "calfda58c59803p", "calendar.yuki.work": "calfda58c59803w", "event.yuki.standup": "sb0d85f9ee0eb5fea7e3b578ae", "event.yuki.sprint": "sbaf9251a02c8af1068cc68858", "event.yuki.oneonone": "sbb07344b2de6eda72b6c9b954", "event.yuki.productsync": "sb8b1e8b4e4d962529e224f9bf", "event.yuki.design": "sb93f8a0abb4787660ab81c268", "event.yuki.customer": "sb37a7074560f0071fae739d5f", "event.yuki.lunch": "sb3d548bac3dfe50dc692b9b4d", "event.yuki.dentist": "sbe7ea582766e506cdaf6d3578", "event.yuki.focus": "sb08665ae27ce9f3a4091183d2", "event.yuki.talk": "sbad20d94320f6a1de32100512", "event.yuki.offsite": "sb9e1a98694ea3bf9b1db8a8dd", "event.yuki.holiday": "sb3e01171d21ec26f595bb22a8", "event.yuki.quarterly": "sb5eb8eb5dc4b140c1ba2c9ce7", "event.yuki.board": "sb9a0396ea96846e4fd3b488b6", "event.yuki.release": "sb425be99e9141e3eb8bbd32b7", "event.yuki.oncall": "sb663fdac0ada858716992b4a3", "event.yuki.retro": "sba7cb09b9032a32d980348453", "event.yuki.hiring": "sbf1d965e6e6b9ea8f94a1d057", "account.jordan": "sbx_fda58c5980_4", "subject.jordan": "sandbox_user_4", "calendar.jordan.primary": "calfda58c59804p", "calendar.jordan.work": "calfda58c59804w", "event.jordan.standup": "sbea6f284d687cb26782e8c636", "event.jordan.sprint": "sb37bbff2a4693584e6f4d6c7e", "event.jordan.oneonone": "sbecbda5d76824198f3e12ef7f", "event.jordan.productsync": "sbf1d4b177a25621cc16590d08", "event.jordan.design": "sb116b030984b5698d5e4c81ec", "event.jordan.customer": "sb217c316443b0d8f2c67ff632", "event.jordan.lunch": "sb532287296e9c7855a7b5e9da", "event.jordan.dentist": "sb3c358ba3551037377d43b981", "event.jordan.focus": "sb1c5c786e842cbee885e6a721", "event.jordan.talk": "sb4b8a5518a89808de94fb57a0", "event.jordan.offsite": "sb8b3352f72b90f105d8e84310", "event.jordan.holiday": "sb97847c4c761dc2c34fce5f9b", "event.jordan.quarterly": "sbbed21aa850958f40d0ddf638", "event.jordan.board": "sb0cc16d90737f55624ce330b0", "event.jordan.release": "sb4da4d05514f7c97665bfcdee", "event.jordan.oncall": "sb28daed7655383de30552be76", "event.jordan.retro": "sb88c16f88bc4751161369a112", "event.jordan.hiring": "sb06d2b6b90745219bb1864b81", "account.riley": "sbx_fda58c5980_5", "subject.riley": "sandbox_user_5", "calendar.riley.primary": "calfda58c59805p", "calendar.riley.work": "calfda58c59805w", "event.riley.standup": "sbeac72e2e596799aad8600049", "event.riley.sprint": "sb1d91fcc56b10d3e962b3dc16", "event.riley.oneonone": "sb5503d314463b2fc4568daf71", "event.riley.productsync": "sb7ba69c6a96e48bf41d632aa9", "event.riley.design": "sb7f567757501e6f8918a68ccf", "event.riley.customer": "sbe5afcb8859a2d26117bada7f", "event.riley.lunch": "sb10b1e81721cb49bbab0c4498", "event.riley.dentist": "sbb67b9d59ef7c6160f31d5438", "event.riley.focus": "sb719ce1d0462de81428df52db", "event.riley.talk": "sb2f5479606451d712d0698f39", "event.riley.offsite": "sb374b1313a775a9061957b191", "event.riley.holiday": "sbb3b186f28c815491bd628c5b", "event.riley.quarterly": "sb506918cbd1f078f6c7682ba2", "event.riley.board": "sbe28ff8c800fe9e46ee632ed3", "event.riley.release": "sb280ea76c6eeb1ebadeecfa5b", "event.riley.oncall": "sb11c31def8bb4d99c166481e6", "event.riley.retro": "sb34c492d642099ac9fbd7a680", "event.riley.hiring": "sb0032a71b261cdfc1fbdd5968", "event.avery.zoomcall": "sb16ba49792a79d2ce856e789a", "account.google": "sbx_fda58c5980_google", "account.outlook": "sbx_fda58c5980_outlook", "account.zoom": "sbx_fda58c5980_zoom", "calendar.google.primary": "avery.brooks@example.com", "calendar.google.team": "c_fda58c5980team@group.calendar.google.com", "calendar.google.holidays": "en.usa#holiday@group.v.calendar.google.com", "calendar.outlook.primary": "AAMkAU662pGNAu0JgQ5Fpjf1wipv7j82pYQuBvDbbTPOq8h4AAA=", "calendar.outlook.acme": "AAMkA-M9ePPI-lDjuHutrLL7ee-FjiW4n60cpd6GPnyDTv84AAA=", "calendar.outlook.morgan": "AAMkAPZCx8r3fRaim7oP6DyRH-4h12pnbtU0iizh-r7n7VpQAAA=", "attachment.google.agenda": "1tTOPj3dD9zJTxnuHOHkvShgQGzYdlWUb", "attachment.outlook.agenda": "AAMkArXFqduOUNsr8qIMCtJ66XOMYtyB4ZLBHoqtmM72Np-gAAA=", "permission.outlook.morgan": "AAMkAO3ALRxedE66VdRlJ7Mztds26zRxlvC0Kb-0ARjadAcQAAA=", "meeting.zoom.standup": "97474246023", "meeting.zoom.demo": "40824382962", "event.google.invite": "sg4fb9224b7476cc20ab4a5c41", "event.google.kickoff": "sg314c0fd5415a58a532cd018d", "event.google.weekly": "sge228430fc2a09c03dd8c1801", "event.google.focus": "sg6297ccf7345c268029ccb5fa", "event.google.trip": "sg6ea8a09f4ce349fe4438c48c", "event.google.office": "sgcd2d8976578edb2f608c814d", "event.google.home": "sg69e86df5bfd6e93979271e83", "event.outlook.review": "AAMkABPQiivgqLU9tm9fMz97PHOQGGb0wy-xR-Eg22SuOCP8AAA=", "event.outlook.invite": "AAMkAaqP10JDPp4BdGjaZyg2dF63m6W8eIe6XhxRsmKdLG38AAA=", "event.outlook.oneonone": "AAMkAfyVo0SAxuZUKe2fHy8vnD8hwmEI2TPen-4bOQRyI85YAAA=", "event.outlook.lunch": "AAMkAmkT0JZAAD-QlweAroEZ60WkpCuHxOrvb1V8lA1M3khEAAA=", "event.outlook.acme": "AAMkAXGIe3xE7fw-FQRwtA9eM3XR5OlTgW90xTZlDQyK9z1MAAA=", "category.outlook.red": "2eb82094-ec40-4d5d-a8b8-15cf273998c1", "category.outlook.orange": "945326ef-02ee-47a7-a625-8364f33d9abd", "category.outlook.yellow": "d4c5c9cb-5502-42de-a335-3f8f85322a98", "category.outlook.green": "109ef2a3-89fc-4525-a7b8-d4d3eddfec23", "category.outlook.blue": "0eed55fa-f6a2-4cb2-a808-1ab178ff93b5", "category.outlook.purple": "18d6d65c-6b1e-47ac-ac6f-5ae45fad5968", "category.google.deepwork": "lbl41c7fe365963", "category.google.travel": "lbld0ade325f932", "group.outlook.mine": "AAMkAPtDnBoVMLgLIu-5ZykUH3m3F8jh-vhqP8YlJhgiZLHEAAA=", "group.outlook.others": "AAMkAyCWrTohk9tAZoyL6-kuuLYadpDmD-3sFfQAb6CXpof0AAA=", "group.outlook.clients": "AAMkAFDoRdCjiMqrQ0gUzJhGn7fWP7a-kYNGIymOmCmTrT7cAAA=", "group.outlook.projects": "AAMkAYE1Kc3zrnSLkXx5fXHBEPOfQSybhu8nRccpNiIpXWVwAAA=", "webhook.inbox": "whk_sbx671d37d5e9995de7b6be", "webhookEvent.inbox": "evt_sbx0b0f2dbfa1198fcb366c", "delivery.inbox": "del_sbxf61f198fc5d9f0072a38" } }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="sandbox-reset" tag="POST" label="/v2/sandbox/reset"} Reset the sandbox :: ::row :::column Deletes everything in the sandbox and repopulates it with the sample accounts, calendars and events. :try-it{operation="sandbox.reset"} ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Errors - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_SANDBOX_MISMATCH`](/errors/AUTH_SANDBOX_MISMATCH) (403): A test key was used with live data, or a live key was used with sandbox data. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/sandbox/reset"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/sandbox/reset' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.sandbox.reset() ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "seededAt": "2026-11-14T20:00:24.721Z" }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="sandbox-simulatechange" tag="POST" label="/v2/sandbox/simulate-change"} Simulate an external change :: ::row :::column Creates, updates or deletes an event the way someone outside your app would, for example in Google Calendar, Microsoft Outlook or another app. Use it to test how your app handles changes it didn't make. Pyas detects the change and sends webhooks as it would for a real account. The webhooks don't include `request`, because the change didn't come from one of your requests. `created` adds an event tomorrow at 16:00 UTC. `updated` renames an event and moves it 30 minutes later. `deleted` deletes the event. If you omit `eventId`, the API uses the next upcoming single event on the calendar. **In the sandbox:** Then check for the webhooks in `GET /v2/webhooks/deliveries` (the sandbox inbox) or in the relay. :try-it{operation="sandbox.simulateChange"} ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="accountId" type="string" required} A sandbox account. Use a virtual account, or the simulated Google or Outlook account. ::::: :::::property{name="change" type="string" required} One of `created`, `updated`, `deleted`. ::::: :::::property{name="eventId" type="string"} ::::: :::::property{name="calendarRef" type="string"} Defaults to `primary`. ::::: :::: ### Errors - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_SANDBOX_MISMATCH`](/errors/AUTH_SANDBOX_MISMATCH) (403): A test key was used with live data, or a live key was used with sandbox data. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) (404): No connected account was found for this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/sandbox/simulate-change"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/sandbox/simulate-change' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "accountId": "sbx_fda58c5980_google", "change": "updated", "eventId": "sg314c0fd5415a58a532cd018d" }' ``` ```js [Node.js] const result = await pyas.sandbox.simulateChange({ "accountId": "sbx_fda58c5980_google", "change": "updated", "eventId": "sg314c0fd5415a58a532cd018d" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "change": "updated", "event": { "id": "sg314c0fd5415a58a532cd018d", "iCalUID": "sg314c0fd5415a58a532cd018d@google.com", "title": "Project Atlas kickoff (changed)", "description": null, "location": null, "startsAt": "2026-11-15T19:30:00Z", "endsAt": "2026-11-15T20:30:00Z", "startsAtLocal": "2026-11-15T14:30:00-05:00", "endsAtLocal": "2026-11-15T15:30:00-05:00", "timezone": "America/New_York", "isAllDay": false, "status": "confirmed", "visibility": null, "attendees": [ { "email": "casey.nguyen@example.com", "name": "Casey Nguyen", "response": "accepted", "optional": false, "isOrganizer": false }, { "email": "drew.okafor@example.com", "name": "Drew Okafor", "response": "needsAction", "optional": false, "isOrganizer": false } ], "rooms": [], "organizer": { "email": "avery.brooks@example.com", "name": "Avery Brooks" }, "conferencing": { "provider": "meet", "joinUrl": "https://meet.google.com/slk-zsog-xml", "meetingId": "slk-zsog-xml", "passcode": null, "dialIn": [ { "label": "+1 555-0100", "number": "+1-555-0100", "pin": "478462722" } ] }, "meetingUrl": "https://meet.google.com/slk-zsog-xml", "recurrence": null, "series": null, "reminders": [], "attachments": [ { "url": "https://docs.google.com/document/d/1tTOPj3dD9zJTxnuHOHkvShgQGzYdlWUb/edit", "title": "Kickoff agenda", "mimeType": "application/vnd.google-apps.document" } ], "showAs": "busy", "eventType": "default", "color": null, "guests": { "canModify": false, "canInviteOthers": true, "canSeeGuests": true, "canProposeTimes": true }, "importance": null, "categories": [], "responseRequested": null, "autoDecline": null, "doNotDisturb": null, "workingLocation": null, "birthday": null, "source": null, "endTimeUnspecified": null, "privateCopy": null, "createdAt": "2026-11-14T20:00:24.212Z", "updatedAt": "2026-11-14T20:00:24.680Z", "version": "\"2\"", "provider": "google", "providerRefs": { "eventId": "sg314c0fd5415a58a532cd018d", "calendarId": "avery.brooks@example.com", "accountId": "sbx_fda58c5980_google" }, "metadata": null } }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="sandbox-simulatemeeting" tag="POST" label="/v2/sandbox/simulate-meeting"} Simulate a Zoom meeting event :: ::row :::column Simulates a Zoom event for a meeting made with the simulated Zoom account, as Zoom would report it: the meeting of a sandbox event (send `eventId`), or one made with `POST /v2/meetings` (send `meetingId`, with the Zoom account as `accountId`). The meeting can start or end, a participant can join or leave, its recording can become ready, or it can be updated or deleted in Zoom. The matching webhook, such as `meeting.started` or `recording.ready`, is sent as it would be for a real Zoom account. **In the sandbox:** Then check for the `recording.ready` webhook in `GET /v2/webhooks/deliveries` (the sandbox inbox) or in the relay. :try-it{operation="sandbox.simulateMeeting"} ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="accountId" type="string" required} The calendar account the event is on. For a meeting made with `POST /v2/meetings`, the Zoom account. ::::: :::::property{name="eventId" type="string"} An event with a Zoom meeting created by the sandbox's Zoom account. ::::: :::::property{name="meetingId" type="string"} Instead of `eventId`: a meeting made with `POST /v2/meetings` and the sandbox's Zoom account. ::::: :::::property{name="what" type="string" required} One of `started`, `ended`, `participant_joined`, `participant_left`, `recording_ready`, `updated`, `deleted`. ::::: :::: ### Errors - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_SANDBOX_MISMATCH`](/errors/AUTH_SANDBOX_MISMATCH) (403): A test key was used with live data, or a live key was used with sandbox data. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/sandbox/simulate-meeting"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/sandbox/simulate-meeting' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "accountId": "sbx_fda58c5980_1", "eventId": "sb16ba49792a79d2ce856e789a", "what": "recording_ready" }' ``` ```js [Node.js] const result = await pyas.sandbox.simulateMeeting({ "accountId": "sbx_fda58c5980_1", "eventId": "sb16ba49792a79d2ce856e789a", "what": "recording_ready" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "what": "recording_ready", "meetingId": "67556028668", "told": 1 }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: --- ::section-heading{id="sandbox-simulatereply" tag="POST" label="/v2/sandbox/simulate-reply"} Simulate an invitation response :: ::row :::column Simulates an attendee of a sandbox event on a virtual calendar responding to its invitation by email, as their calendar app would. The response can be `accepted`, `declined` or `tentative`, with a comment and, optionally, a proposed alternative time. Pyas records the response on the attendee and sends `invitation.replied` and `event.updated`, as it would for a real reply. **In the sandbox:** Then check for `invitation.replied` in `GET /v2/webhooks/deliveries` (the sandbox inbox), and check the attendee's response on the event. :try-it{operation="sandbox.simulateReply"} ### Headers ::::properties :::::property{name="Idempotency-Key" type="string"} A unique key that makes the request safe to retry. A retry with the same key returns the original response and does not make the change twice. Keys are kept for 24 hours. ::::: :::: ### Body ::::properties :::::property{name="accountId" type="string" required} A virtual account in the sandbox. ::::: :::::property{name="eventId" type="string" required} ::::: :::::property{name="email" type="string" required} The email address of an attendee of the event. ::::: :::::property{name="response" type="string" required} One of `accepted`, `declined`, `tentative`. ::::: :::::property{name="comment" type="string"} ::::: :::::property{name="proposedTime" type="object"} ::::: :::::property{name="proposedTime.startsAt" type="string" required} ::::: :::::property{name="proposedTime.endsAt" type="string" required} ::::: :::: ### Errors - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) (400): The request body is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_SANDBOX_MISMATCH`](/errors/AUTH_SANDBOX_MISMATCH) (403): A test key was used with live data, or a live key was used with sandbox data. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="POST" label="/v2/sandbox/simulate-reply"} ```bash [cURL] curl -X POST 'https://api.pyas.io/v2/sandbox/simulate-reply' \ -H "x-api-key: $PYAS_API_KEY" \ -H "content-type: application/json" \ -d '{ "accountId": "sbx_fda58c5980_1", "eventId": "sbff8ee84b7939a1d7a4943ff6", "email": "teammate2.1@example.com", "response": "tentative", "comment": "Could we start at 3:30?", "proposedTime": { "startsAt": "2026-11-15T15:30:00Z", "endsAt": "2026-11-15T16:30:00Z" } }' ``` ```js [Node.js] const result = await pyas.sandbox.simulateReply({ "accountId": "sbx_fda58c5980_1", "eventId": "sbff8ee84b7939a1d7a4943ff6", "email": "teammate2.1@example.com", "response": "tentative", "comment": "Could we start at 3:30?", "proposedTime": { "startsAt": "2026-11-15T15:30:00Z", "endsAt": "2026-11-15T16:30:00Z" } }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "recorded": true, "eventId": "sbff8ee84b7939a1d7a4943ff6" }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: # Usage ::lead Retrieve API usage by day. :: ::note The examples use IDs from a sample sandbox. Your sandbox has the same people, calendars and events with its own IDs, which `GET /v2/sandbox` returns. When you're signed in to the dashboard, **Try it** fills in your IDs and sends the request to your sandbox, never to live data. :: --- ::section-heading{id="usage-get" tag="GET" label="/v2/usage"} Get usage by day :: ::row :::column Returns your organization's requests per day on v1, v2 and test keys, the share of requests made to v2, and the busiest endpoints. Usage is counted every 10 seconds, so today's figures can be up to 10 seconds behind. :try-it{operation="usage.get"} ### Query parameters ::::properties :::::property{name="from" type="string (date)"} The first day (UTC), such as 2026-10-01. ::::: :::::property{name="to" type="string (date)"} The last day (UTC), such as 2026-10-01. ::::: :::::property{name="appId" type="string"} Returns usage for this app only. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/usage"} ```bash [cURL] curl 'https://api.pyas.io/v2/usage' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.usage.get() ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "totals": { "v1": 0, "v2": 0, "test": 0 }, "v2Share": null, "days": [], "topEndpoints": [] }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": { "from": "2026-10-16", "to": "2026-11-14" } } ``` :::: ::: :: # Logs ::lead Look up any v2 request by its trace ID to see what was requested, what the API responded and why, and what happened while the request was processed. :: ::note The examples use IDs from a sample sandbox. Your sandbox has the same people, calendars and events with its own IDs, which `GET /v2/sandbox` returns. When you're signed in to the dashboard, **Try it** fills in your IDs and sends the request to your sandbox, never to live data. :: --- ::section-heading{id="logs-list" tag="GET" label="/v2/logs"} List request logs :: ::row :::column Returns the app's v2 requests, newest first. Each log includes the status, error code and hint, and what happened while the request was processed: operations, provider requests, token refreshes, and the webhooks and invitations it triggered. A test key lists the sandbox's requests. Logs are kept for 7 days, or 14 days on Growth and 30 days on Scale. Request and response bodies are never stored. :try-it{operation="logs.list"} ### Query parameters ::::properties :::::property{name="accountId" type="string"} ::::: :::::property{name="subjectId" type="string"} ::::: :::::property{name="outcome" type="string"} `failed` returns requests that received a 4xx or 5xx response. `succeeded` returns requests that received a 2xx or 3xx response. One of `failed`, `succeeded`. ::::: :::::property{name="status" type="integer"} An HTTP status code. From 100 to 599. ::::: :::::property{name="errorCode" type="string"} An error code, such as `CONNECTION_REVOKED`. ::::: :::::property{name="route" type="string"} An endpoint, as it appears in `route`, such as "POST /v2/events". ::::: :::::property{name="since" type="string (date-time)"} Returns only requests made at or after this time. ::::: :::::property{name="until" type="string (date-time)"} Returns only requests made before this time. ::::: :::::property{name="limit" type="integer"} The maximum number of results in one page. From 1 to 100. ::::: :::::property{name="cursor" type="string"} The `nextCursor` from the previous page. ::::: :::: ### Errors - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) (400): A query parameter is not valid. - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/logs"} ```bash [cURL] curl 'https://api.pyas.io/v2/logs?outcome=failed' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.logs.list({ "outcome": "failed" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": [], "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": { "nextCursor": null } } ``` :::: ::: :: --- ::section-heading{id="logs-get" tag="GET" label="/v2/logs/{traceId}"} Get a request log :: ::row :::column Returns one request by its trace ID, which is the `X-Trace-Id` header of any v2 response or the `traceId` of an error. Logs are written within a few seconds of the response. **In the sandbox:** Use the `X-Trace-Id` header from any earlier response, or the `traceId` from an error. Logs are written a few seconds after the response is sent. :try-it{operation="logs.get"} ### Path parameters ::::properties :::::property{name="traceId" type="string" required} ::::: :::: ### Errors - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) (401): The API key is missing, unknown or revoked. - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) (401): The API key has expired. - [`AUTH_INSUFFICIENT_SCOPE`](/errors/AUTH_INSUFFICIENT_SCOPE) (403): The API key or session does not have permission to make this request. - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) (404): The calendar or event was not found. - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) (429): The request rate exceeds this plan's rate limit. - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) (429): Too many requests were sent in a short period. - [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) (500): Something went wrong on our end. ::: :::column{sticky} ::::code-group{title="Request" tag="GET" label="/v2/logs/{traceId}"} ```bash [cURL] curl 'https://api.pyas.io/v2/logs/trc_000000000000000000000000' \ -H "x-api-key: $PYAS_API_KEY" ``` ```js [Node.js] const result = await pyas.logs.get({ "traceId": "trc_000000000000000000000000" }) ``` :::: ::::code-group{title="Response" label="200 OK"} ```json { "success": true, "data": { "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "createdAt": "2026-11-14T20:00:32.997Z", "mode": "test", "keyId": null, "client": null, "method": "GET", "route": "GET /v2/calendars", "operationId": "calendars.list", "path": "/v2/calendars", "status": 200, "durationMs": 4, "accountId": "sbx_fda58c5980_1", "subjectId": null, "error": null, "idempotencyKey": null, "replayed": false, "cache": "MISS", "queuedMs": null, "operations": [ { "provider": "pyas", "operation": "listCalendars", "accountId": "sbx_fda58c5980_1", "durationMs": 1 } ], "providerCalls": [], "tokenRefreshes": [], "webhookEvents": [], "invitations": [], "omitted": 0 }, "provider": null, "traceId": "trc_5f2a9c1e7b3d4a6f8e0c2b1d", "meta": {} } ``` :::: ::: :: # AUTH_EXPIRED_KEY ::lead The API key has expired. :: HTTP status: **401**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Use the current key from the dashboard (API keys) and remove the old one from your configuration. Hint returned with the error: _Use the key that replaced it. The rotation grace period for this key has ended._ ::section-heading{id="why"} Why it happens :: - The key was rotated and its grace period ended ::section-heading{id="related"} Related errors :: - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) # AUTH_EXPIRED_SESSION ::lead The session has expired. :: HTTP status: **401**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Sign in again. Hint returned with the error: _Sign in again._ ::section-heading{id="why"} Why it happens :: - Inactivity for longer than the session lifetime ::section-heading{id="related"} Related errors :: - [`AUTH_INVALID_SESSION`](/errors/AUTH_INVALID_SESSION) # AUTH_INSUFFICIENT_SCOPE ::lead The API key or session does not have permission to make this request. :: HTTP status: **403**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Create a key with the required scope, or ask an organization owner to grant you the permission. Hint returned with the error: _Use an API key or session that has the permission this endpoint requires._ ::section-heading{id="why"} Why it happens :: - A restricted key used on an endpoint outside its scope - A team role without this permission ::section-heading{id="related"} Related errors :: - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) # AUTH_INVALID_CONNECT_SESSION ::lead The connect session is missing, invalid, expired or already used. :: HTTP status: **401**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Create a new connect session (`POST /v2/connect/sessions`) for each connection, and use it within 10 minutes. Hint returned with the error: _Create a new connect session on your server and try again._ ::section-heading{id="why"} Why it happens :: - The session is older than 10 minutes - The session was already used to connect an account - The token was copied incorrectly ::section-heading{id="related"} Related errors :: - [`AUTH_ORIGIN_NOT_ALLOWED`](/errors/AUTH_ORIGIN_NOT_ALLOWED) # AUTH_INVALID_KEY ::lead The API key is missing, unknown or revoked. :: HTTP status: **401**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Copy the key again from the dashboard (API keys) and send it as `x-api-key: `, not as a bearer token. Hint returned with the error: _Send a valid key in the `x-api-key` header. You can manage your keys in the Pyas dashboard._ ::section-heading{id="why"} Why it happens :: - A missing `x-api-key` header - A typo in the key - The key was revoked or deleted ::section-heading{id="related"} Related errors :: - [`AUTH_EXPIRED_KEY`](/errors/AUTH_EXPIRED_KEY) - [`AUTH_SANDBOX_MISMATCH`](/errors/AUTH_SANDBOX_MISMATCH) # AUTH_INVALID_SESSION ::lead The session is missing, invalid or revoked. :: HTTP status: **401**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Sign in again. If this keeps happening, allow cookies for the dashboard. Hint returned with the error: _Sign in again._ ::section-heading{id="why"} Why it happens :: - The user signed out in another tab - The session was revoked - The browser blocks cookies ::section-heading{id="related"} Related errors :: - [`AUTH_EXPIRED_SESSION`](/errors/AUTH_EXPIRED_SESSION) # AUTH_KEY_IN_BROWSER ::lead A live API key was sent from a web page. Live keys only work from a server. :: HTTP status: **403**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Make the request from your server (an API route, or a serverless or edge function) with the key kept as a secret, then rotate the key in the dashboard. Code in the browser never needs a key: to connect accounts, your server creates a connect session and the page uses its token. Hint returned with the error: _Move this request to your server and keep the key in a server secret. Rotate the key, since anyone who opened the page could read it._ ::section-heading{id="why"} Why it happens :: - The key is in front-end code, so every visitor to the page can read it - An AI app builder put the API call in the browser instead of a server function ::section-heading{id="related"} Related errors :: - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) - [`AUTH_INVALID_CONNECT_SESSION`](/errors/AUTH_INVALID_CONNECT_SESSION) # AUTH_ORIGIN_NOT_ALLOWED ::lead The request's origin is not in the app's allowed origins. :: HTTP status: **403**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Add the exact origin, including scheme, host and port, to the app's allowed origins. Hint returned with the error: _Add the site's origin to the app's allowed origins in the dashboard._ ::section-heading{id="why"} Why it happens :: - A new domain or port that has not been added to the app yet - A scheme or subdomain mismatch (`http` and `https`, or `www` and no `www`, are different origins) ::section-heading{id="related"} Related errors :: - [`AUTH_INVALID_CONNECT_SESSION`](/errors/AUTH_INVALID_CONNECT_SESSION) # AUTH_SANDBOX_MISMATCH ::lead A test key was used with live data, or a live key was used with sandbox data. :: HTTP status: **403**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Check which key your environment loads. Test keys start with `pyas_sk_test_`. Live keys start with `pyas_sk_` and do not include `test_`. To test connecting accounts in the sandbox, use PyasConnect with a connect session created with your test key. Hint returned with the error: _Use a test key for sandbox requests and a live key for real connections._ ::section-heading{id="why"} Why it happens :: - API keys swapped between environments - A request that only works with real accounts was sent with a test key: an account import, or connecting without PyasConnect (`GET /v2/connect/oauth`) ::section-heading{id="related"} Related errors :: - [`AUTH_INVALID_KEY`](/errors/AUTH_INVALID_KEY) # BILLING_CONNECTION_LIMIT ::lead The plan's connected account limit has been reached. :: HTTP status: **402**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Upgrade the plan in the dashboard (Billing), or delete connected accounts you no longer need. Hint returned with the error: _Upgrade the plan or delete unused connected accounts._ ::section-heading{id="why"} Why it happens :: - More connected accounts than the plan includes ::section-heading{id="related"} Related errors :: - [`BILLING_FEATURE_UNAVAILABLE`](/errors/BILLING_FEATURE_UNAVAILABLE) # BILLING_FEATURE_UNAVAILABLE ::lead This feature is not included in the current plan. :: HTTP status: **403**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Upgrade the plan in the dashboard (Billing). Hint returned with the error: _Upgrade the plan to use this feature._ ::section-heading{id="why"} Why it happens :: - A feature that requires a higher plan ::section-heading{id="related"} Related errors :: - [`BILLING_CONNECTION_LIMIT`](/errors/BILLING_CONNECTION_LIMIT) # BILLING_OVERDUE ::lead The account has an overdue payment. :: HTTP status: **402**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Update the payment method in the dashboard (Billing). Service resumes once the payment succeeds. Hint returned with the error: _Update the payment method in the dashboard._ ::section-heading{id="why"} Why it happens :: - A failed card payment ::section-heading{id="related"} Related errors :: - [`BILLING_FEATURE_UNAVAILABLE`](/errors/BILLING_FEATURE_UNAVAILABLE) # BILLING_SUBSCRIPTION_REQUIRED ::lead The organization has no active subscription. :: HTTP status: **402**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Choose or renew a plan in the dashboard (Billing). Hint returned with the error: _Choose a plan in the dashboard (Billing)._ ::section-heading{id="why"} Why it happens :: - No plan has been chosen - The subscription was canceled or has ended ::section-heading{id="related"} Related errors :: - [`BILLING_OVERDUE`](/errors/BILLING_OVERDUE) # CONNECTION_CONSENT_FAILED ::lead The provider did not complete the connection. :: HTTP status: **422**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Start a new connect session. If it keeps failing for one user, check that their administrator allows the app. Hint returned with the error: _Ask the user to connect again._ ::section-heading{id="why"} Why it happens :: - The user canceled or declined consent - The authorization code expired or was already used - The provider returned no refresh token or no email address for the account ::section-heading{id="related"} Related errors :: - [`CONNECTION_PROVIDER_NOT_CONFIGURED`](/errors/CONNECTION_PROVIDER_NOT_CONFIGURED) # CONNECTION_NOT_FOUND ::lead No connected account was found for this request. :: HTTP status: **404**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: List the app's connected accounts with `GET /v2/accounts` to find the correct `accountId`, or connect the account with PyasConnect. Hint returned with the error: _The user may not have connected this provider yet. Start a connection with PyasConnect._ ::section-heading{id="why"} Why it happens :: - The `accountId` is wrong or belongs to another app - The user never connected this provider - The connected account was deleted ::section-heading{id="related"} Related errors :: - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) # CONNECTION_PROVIDER_NOT_CONFIGURED ::lead The app is not configured to connect accounts from this provider. :: HTTP status: **422**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Enter the client ID, client secret and redirect URI for Native Auth, or switch the app back to Pyas Auth. For a Google feature Pyas Auth cannot ask for yet, set up Native Auth for Google or leave the feature out. Hint returned with the error: _Complete the provider's OAuth settings for the app in the dashboard._ ::section-heading{id="why"} Why it happens :: - The app uses Native Auth for this provider, and its client ID, client secret or callback URL is missing - The app uses Pyas Auth for Google and asked for a feature (`manageCalendars`, `sharing`, `meetingSettings` or `rooms`) whose permissions Google has not approved for Pyas's Google app yet ::section-heading{id="related"} Related errors :: - [`CONNECTION_CONSENT_FAILED`](/errors/CONNECTION_CONSENT_FAILED) # CONNECTION_REVOKED ::lead The user revoked access, or the provider no longer accepts the stored credentials. :: HTTP status: **422**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Have the user reconnect through PyasConnect. Listen for the `connection.broken` webhook to detect this early. Hint returned with the error: _Ask the user to reconnect._ ::section-heading{id="why"} Why it happens :: - The user removed the app from their Google or Microsoft account - An administrator revoked access - The user changed their password and the provider invalidated the tokens ::section-heading{id="related"} Related errors :: - [`CONNECTION_SCOPE_INSUFFICIENT`](/errors/CONNECTION_SCOPE_INSUFFICIENT) - [`CONNECTION_TOKEN_EXPIRED`](/errors/CONNECTION_TOKEN_EXPIRED) # CONNECTION_SCOPE_INSUFFICIENT ::lead The connected account did not grant a permission that this operation requires. :: HTTP status: **422**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Have the user reconnect through PyasConnect and accept every permission. Hint returned with the error: _Ask the user to reconnect and approve all requested permissions._ ::section-heading{id="why"} Why it happens :: - The user unchecked a permission on the consent screen - The operation requires a scope that was added after the user connected ::section-heading{id="related"} Related errors :: - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) # CONNECTION_TOKEN_EXPIRED ::lead The provider's access token expired and could not be refreshed. :: HTTP status: **422**. Retrying the same request may succeed. ::section-heading{id="what-to-do"} What to do :: Retry with backoff. If the error persists, have the user reconnect through PyasConnect. Hint returned with the error: _Retry the request shortly. If it keeps failing, ask the user to reconnect their account._ ::section-heading{id="why"} Why it happens :: - A temporary provider outage during refresh - The refresh token was invalidated ::section-heading{id="related"} Related errors :: - [`CONNECTION_REVOKED`](/errors/CONNECTION_REVOKED) - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) # CONNECT_CANCELLED ::lead The user closed the widget before connecting. :: HTTP status: **none (PyasConnect reports this error in the browser)**. Retrying the same request may succeed. ::section-heading{id="what-to-do"} What to do :: No action is needed. If your page should react, listen for the `cancel` event. Hint returned with the error: _No action is needed. The user can start again at any time._ ::section-heading{id="why"} Why it happens :: - The user closed the dialog, pressed Escape or chose Cancel ::section-heading{id="related"} Related errors :: - [`CONNECT_TIMEOUT`](/errors/CONNECT_TIMEOUT) # CONNECT_CONSENT_DENIED ::lead The user did not grant access at the provider. :: HTTP status: **none (PyasConnect reports this error in the browser)**. Retrying the same request may succeed. ::section-heading{id="what-to-do"} What to do :: Explain how your app uses the calendar next to the connect button, then let the user try again. Hint returned with the error: _Let the user try again, and explain why your app needs access to their calendar._ ::section-heading{id="why"} Why it happens :: - The user chose Cancel or Deny on the consent screen - An administrator blocks the app for the user's organization ::section-heading{id="related"} Related errors :: - [`CONNECTION_CONSENT_FAILED`](/errors/CONNECTION_CONSENT_FAILED) # CONNECT_INVALID_CONFIG ::lead The connect widget is not configured correctly. :: HTTP status: **none (PyasConnect reports this error in the browser)**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Create a connect session on your server (`POST /v2/connect/sessions`) and pass its token to the widget. Hint returned with the error: _Pass the widget a session token from your server, or a function that fetches one._ ::section-heading{id="why"} Why it happens :: - No session token, and no 1.x client ID and token name - A provider in `providers` that the widget does not support - Redirect mode used with 1.x keys ::section-heading{id="related"} Related errors :: - [`AUTH_INVALID_CONNECT_SESSION`](/errors/AUTH_INVALID_CONNECT_SESSION) # CONNECT_NETWORK_ERROR ::lead The browser could not connect to the Pyas API. :: HTTP status: **none (PyasConnect reports this error in the browser)**. Retrying the same request may succeed. ::section-heading{id="what-to-do"} What to do :: Check that the page allows `https://api.pyas.io` in `connect-src` and that `api-url` is correct. Otherwise, let the user try again. Hint returned with the error: _Let the user try again once they are back online._ ::section-heading{id="why"} Why it happens :: - The user is offline or has an unstable connection - A Content Security Policy that does not allow the Pyas API in `connect-src` - An incorrect `api-url` ::section-heading{id="related"} Related errors :: - [`CONNECT_TIMEOUT`](/errors/CONNECT_TIMEOUT) # CONNECT_POPUP_BLOCKED ::lead The browser blocked the sign-in window. :: HTTP status: **none (PyasConnect reports this error in the browser)**. Retrying the same request may succeed. ::section-heading{id="what-to-do"} What to do :: Call `open()` directly from the click handler. Where pop-ups don't work, such as in in-app browsers, set `mode="redirect"` and use a session that has a `returnUrl`. Hint returned with the error: _Open the widget directly from a click handler, or use redirect mode._ ::section-heading{id="why"} Why it happens :: - `open()` or the connect button was called after an `await`, outside the click handler - A strict pop-up blocker or an in-app browser ::section-heading{id="related"} Related errors :: - [`CONNECT_TIMEOUT`](/errors/CONNECT_TIMEOUT) # CONNECT_PROVIDER_ERROR ::lead The calendar provider could not finish the sign-in. :: HTTP status: **none (PyasConnect reports this error in the browser)**. Retrying the same request may succeed. ::section-heading{id="what-to-do"} What to do :: Let the user try again. If it keeps happening with one provider, check the provider's status page and your provider settings in the dashboard. Hint returned with the error: _Let the user try again, or choose another provider._ ::section-heading{id="why"} Why it happens :: - The provider responded with an error other than a refusal - The provider is experiencing issues ::section-heading{id="related"} Related errors :: - [`CONNECT_CONSENT_DENIED`](/errors/CONNECT_CONSENT_DENIED) - [`CONNECTION_CONSENT_FAILED`](/errors/CONNECTION_CONSENT_FAILED) # CONNECT_SESSION_FAILED ::lead Your server could not create a connect session. :: HTTP status: **none (PyasConnect reports this error in the browser)**. Retrying the same request may succeed. ::section-heading{id="what-to-do"} What to do :: Open your session endpoint's logs, set its live API key, and make it return `{ token }`. The person can try again from the dialog. Hint returned with the error: _Check your session endpoint: the error event's message includes what it returned._ ::section-heading{id="why"} Why it happens :: - The endpoint behind `getSessionToken` failed or could not be reached - It responded without a `token` (it should return `{ "token": "pcs_..." }`, as the SDK's `sessionHandler` does) - Your server has no live API key set ::section-heading{id="related"} Related errors :: - [`CONNECT_NETWORK_ERROR`](/errors/CONNECT_NETWORK_ERROR) - [`AUTH_INVALID_CONNECT_SESSION`](/errors/AUTH_INVALID_CONNECT_SESSION) # CONNECT_TIMEOUT ::lead The sign-in timed out. :: HTTP status: **none (PyasConnect reports this error in the browser)**. Retrying the same request may succeed. ::section-heading{id="what-to-do"} What to do :: Let the user try again. The widget shows a retry option on its own screen. To log the error, listen for the `error` event. Hint returned with the error: _Let the user try again. If your users often need more time, increase `consentTimeoutMs`._ ::section-heading{id="why"} Why it happens :: - The user left the sign-in window open without finishing - A slow two-step verification at the provider ::section-heading{id="related"} Related errors :: - [`CONNECT_CANCELLED`](/errors/CONNECT_CANCELLED) - [`CONNECT_POPUP_BLOCKED`](/errors/CONNECT_POPUP_BLOCKED) # INTERNAL_ERROR ::lead Something went wrong on our end. :: HTTP status: **500**. Retrying the same request may succeed. ::section-heading{id="what-to-do"} What to do :: Retry with backoff. Every occurrence is reported to Pyas. If the error persists, contact support and include the trace ID. Hint returned with the error: _Retry the request. If the error persists, contact support and include the trace ID._ ::section-heading{id="why"} Why it happens :: - A bug or an outage in Pyas ::section-heading{id="related"} Related errors :: - [`PROVIDER_UNKNOWN_ERROR`](/errors/PROVIDER_UNKNOWN_ERROR) # PROVIDER_PARTIAL_SUCCESS ::lead Some operations succeeded and some failed. :: HTTP status: **207**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Read the result of each operation, fix the failed operations, and resend only those. Hint returned with the error: _Check the result of each operation. Only the failed operations need attention._ ::section-heading{id="why"} Why it happens :: - One item in a batch was rejected while the others succeeded ::section-heading{id="related"} Related errors :: - [`VALIDATION_REJECTED_BY_PROVIDER`](/errors/VALIDATION_REJECTED_BY_PROVIDER) # PROVIDER_RATE_LIMITED ::lead The calendar provider is rate limiting requests for this account. :: HTTP status: **429**. Retrying the same request may succeed. ::section-heading{id="what-to-do"} What to do :: Retry after the `Retry-After` interval, and spread bulk work over time. Use webhooks instead of polling. Hint returned with the error: _Wait for the interval in the `Retry-After` header, then retry the request._ ::section-heading{id="why"} Why it happens :: - Many requests for one account in a short time - Provider-wide throttling ::section-heading{id="related"} Related errors :: - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) # PROVIDER_TIMEOUT ::lead The calendar provider did not respond in time. :: HTTP status: **504**. Retrying the same request may succeed. ::section-heading{id="what-to-do"} What to do :: Retry with backoff, and narrow the `from` and `to` window for large reads. Hint returned with the error: _Retry the request. For large ranges, request a smaller time window._ ::section-heading{id="why"} Why it happens :: - A slow provider response - A very large time window or calendar ::section-heading{id="related"} Related errors :: - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) # PROVIDER_UNAVAILABLE ::lead The calendar provider is not responding normally. :: HTTP status: **503**. Retrying the same request may succeed. ::section-heading{id="what-to-do"} What to do :: Retry with exponential backoff. Check the provider's status page for ongoing incidents. Hint returned with the error: _Retry the request with exponential backoff._ ::section-heading{id="why"} Why it happens :: - A Google or Microsoft outage - Provider maintenance ::section-heading{id="related"} Related errors :: - [`PROVIDER_TIMEOUT`](/errors/PROVIDER_TIMEOUT) # PROVIDER_UNKNOWN_ERROR ::lead The calendar provider returned an error that Pyas could not classify. :: HTTP status: **502**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Contact support and include the trace ID. Pyas is notified of every occurrence, and classifies or fixes each one. Hint returned with the error: _Retry the request once. If the error repeats, contact support and include the trace ID._ ::section-heading{id="why"} Why it happens :: - A provider error that Pyas has not seen before ::section-heading{id="related"} Related errors :: - [`PROVIDER_UNAVAILABLE`](/errors/PROVIDER_UNAVAILABLE) # RATE_LIMIT_BURST ::lead Too many requests were sent in a short period. :: HTTP status: **429**. Retrying the same request may succeed. ::section-heading{id="what-to-do"} What to do :: Add a short delay between requests, or queue them. Hint returned with the error: _Wait a moment, then spread your requests out over time._ ::section-heading{id="why"} Why it happens :: - Many requests in the same second ::section-heading{id="related"} Related errors :: - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) # RATE_LIMIT_EXCEEDED ::lead The request rate exceeds this plan's rate limit. :: HTTP status: **429**. Retrying the same request may succeed. ::section-heading{id="what-to-do"} What to do :: Wait for the `Retry-After` interval, cache reads, and use webhooks instead of polling. Higher plans have higher limits. Hint returned with the error: _Wait for the interval in the `Retry-After` header, then retry the request._ ::section-heading{id="why"} Why it happens :: - Sustained traffic above the plan's rate limit ::section-heading{id="related"} Related errors :: - [`RATE_LIMIT_BURST`](/errors/RATE_LIMIT_BURST) - [`PROVIDER_RATE_LIMITED`](/errors/PROVIDER_RATE_LIMITED) # RATE_MONTHLY_QUOTA_EXCEEDED ::lead This plan's monthly API request limit has been reached. :: HTTP status: **429**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Upgrade the plan in the dashboard (Billing). To make fewer requests, cache reads and use webhooks instead of polling. Hint returned with the error: _Upgrade the plan, or wait for the limit to reset at the start of next month._ ::section-heading{id="why"} Why it happens :: - More API requests this month than the plan includes ::section-heading{id="related"} Related errors :: - [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) - [`BILLING_SUBSCRIPTION_REQUIRED`](/errors/BILLING_SUBSCRIPTION_REQUIRED) # RESOURCE_CONFLICT ::lead The resource was modified or already exists. :: HTTP status: **409**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Fetch the latest version, reapply your change, and send it with the new `version` in the `If-Match` header. Hint returned with the error: _Fetch the resource again and retry with its current version._ ::section-heading{id="why"} Why it happens :: - Someone else updated the event after you read it (`If-Match` did not match) - An attempt to create a resource that already exists ::section-heading{id="related"} Related errors :: - [`VALIDATION_IDEMPOTENCY_CONFLICT`](/errors/VALIDATION_IDEMPOTENCY_CONFLICT) # RESOURCE_LIMIT_REACHED ::lead The limit for this resource has been reached. :: HTTP status: **409**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Delete items you no longer need, or reset the sandbox with `POST /v2/sandbox/reset`. Hint returned with the error: _Delete existing items first. The error message includes the limit._ ::section-heading{id="why"} Why it happens :: - The sandbox's limits on virtual accounts, calendars or events (test keys) ::section-heading{id="related"} Related errors :: - [`RATE_MONTHLY_QUOTA_EXCEEDED`](/errors/RATE_MONTHLY_QUOTA_EXCEEDED) # RESOURCE_NOT_FOUND ::lead The calendar or event was not found. :: HTTP status: **404**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: List the calendars or events again to get their current IDs. Hint returned with the error: _Check the ID. The item may have been deleted, or it may not be visible to this account._ ::section-heading{id="why"} Why it happens :: - A wrong or outdated ID - The item was deleted - The account does not have access to this calendar ::section-heading{id="related"} Related errors :: - [`CONNECTION_NOT_FOUND`](/errors/CONNECTION_NOT_FOUND) # RESOURCE_SYNC_TOKEN_EXPIRED ::lead The sync token has expired. :: HTTP status: **410**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Discard the stored `syncToken`, list the events again from the beginning, and store the `syncToken` from the last page. Hint returned with the error: _Request the full list again without a `syncToken`, and store the new `syncToken` from the response._ ::section-heading{id="why"} Why it happens :: - The token is older than the provider's change history - The calendar was reset or its sharing settings changed ::section-heading{id="related"} Related errors :: - [`RESOURCE_NOT_FOUND`](/errors/RESOURCE_NOT_FOUND) # VALIDATION_ENDPOINT_UNREACHABLE ::lead The webhook endpoint did not accept a test delivery. :: HTTP status: **422**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Check the error message and fix the endpoint. Then reactivate it with `PATCH /v2/webhooks/:webhookId` and the body `{ "status": "active" }`. Hint returned with the error: _Make sure the endpoint responds with a 2xx status to a signed `POST` request, then try again._ ::section-heading{id="why"} Why it happens :: - Reactivating a webhook endpoint that still fails (Pyas sends a test delivery first) - The endpoint is down, responds with a non-2xx status, doesn't respond within 10 seconds, or resolves to a private network ::section-heading{id="related"} Related errors :: - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) # VALIDATION_IDEMPOTENCY_CONFLICT ::lead This idempotency key was already used with a different request. :: HTTP status: **422**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Generate a new idempotency key for each distinct request. Reuse a key only to retry the same request. Hint returned with the error: _Use a new idempotency key for a new request._ ::section-heading{id="why"} Why it happens :: - The same key reused for different operations ::section-heading{id="related"} Related errors :: - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) # VALIDATION_INVALID_BODY ::lead The request body is not valid. :: HTTP status: **400**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Correct the fields listed in the error message and send the request again. Hint returned with the error: _Check the fields listed in the error message against the API reference._ ::section-heading{id="why"} Why it happens :: - A missing required field - A field of the wrong type - Malformed JSON ::section-heading{id="related"} Related errors :: - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) # VALIDATION_INVALID_QUERY ::lead A query parameter is not valid. :: HTTP status: **400**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Correct the parameters listed in the error message. Hint returned with the error: _Check the parameters listed in the error message against the API reference._ ::section-heading{id="why"} Why it happens :: - An unknown parameter value - A date that is not in ISO 8601 format - `from` is later than `to` ::section-heading{id="related"} Related errors :: - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) - [`VALIDATION_INVALID_TIMEZONE`](/errors/VALIDATION_INVALID_TIMEZONE) # VALIDATION_INVALID_TIMEZONE ::lead The time zone is not an IANA time zone name. :: HTTP status: **400**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Send an IANA time zone name, for example `America/New_York` or `Europe/London`. Hint returned with the error: _Use an IANA time zone name such as `America/New_York`, not an abbreviation or an offset._ ::section-heading{id="why"} Why it happens :: - An abbreviation such as `EST` - A Windows time zone name such as `Eastern Standard Time` - An offset such as `-05:00` ::section-heading{id="related"} Related errors :: - [`VALIDATION_INVALID_QUERY`](/errors/VALIDATION_INVALID_QUERY) # VALIDATION_PAYLOAD_TOO_LARGE ::lead The request body exceeds the size limit for this endpoint. :: HTTP status: **413**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Keep files within the limit in the error message, send a `Content-Length` header with large files, or attach a link to the file instead. Hint returned with the error: _Send a smaller request body. The error message includes the limit._ ::section-heading{id="why"} Why it happens :: - An attachment larger than the account's limit: 25 MB through Pyas, shown in its status as `capabilities.maxAttachmentBytes` - A file larger than 25 MB sent without a `Content-Length` header ::section-heading{id="related"} Related errors :: - [`VALIDATION_INVALID_BODY`](/errors/VALIDATION_INVALID_BODY) # VALIDATION_REJECTED_BY_PROVIDER ::lead The calendar provider rejected this input. :: HTTP status: **422**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Change the input as described in the error message. The connected account's capabilities describe what each provider supports. Hint returned with the error: _Change the input as described in the error message. Some values are valid in Pyas but not supported by every provider._ ::section-heading{id="why"} Why it happens :: - A recurrence rule the provider cannot represent - An attendee address the provider rejects - A value outside the provider's limits ::section-heading{id="related"} Related errors :: - [`VALIDATION_UNSUPPORTED_OPERATION`](/errors/VALIDATION_UNSUPPORTED_OPERATION) # VALIDATION_UNSUPPORTED_OPERATION ::lead This provider does not support this operation. :: HTTP status: **422**. Retrying the same request returns the same error. ::section-heading{id="what-to-do"} What to do :: Check the connected account's capabilities, and skip or replace the operation for providers that do not support it. Hint returned with the error: _Check the connected account's capabilities before making the request._ ::section-heading{id="why"} Why it happens :: - Calendar writes on a provider that does not support them - Incremental sync on a provider that does not support it ::section-heading{id="related"} Related errors :: - [`VALIDATION_REJECTED_BY_PROVIDER`](/errors/VALIDATION_REJECTED_BY_PROVIDER)