# Google Workspace

Connect a Google account to read and send Gmail, manage Google Calendar events, work with Drive files and sync Google Contacts.

A single Google connection gives Pozy access to one person's Gmail, Google Calendar, Google Drive and Google Contacts, as one account per mailbox. At connect time you decide which of those four areas to ask for. Ask for more later and the account id stays the same. The calls themselves are the same for Google and Microsoft mailboxes: see [Email](https://pozy.ai/docs/productivity/email), [Calendar](https://pozy.ai/docs/productivity/calendar) and [Files](https://pozy.ai/docs/productivity/files).

## What you can do

| Area | Value in `scopes` | Google permission | What it unlocks |
| --- | --- | --- | --- |
| Email | `email` | `gmail.modify` | List, search, read, send, reply, label, archive and trash Gmail |
| Calendar | `calendar` | `calendar` | Events with Google Meet links, RSVP, free-busy and availability |
| Files | `files` | `drive.file` | Only files created through Pozy or picked by the user |
| Contacts | `contacts` | `contacts.readonly` | Google Contacts copied into `/v1/contacts` once a day, read-only |

On top of those, each connection requests `openid`, `userinfo.email` and `userinfo.profile`, which tell Pozy whose mailbox it is.

| Property | Value |
| --- | --- |
| Platform id | `google` |
| Accounts | One per Google account and profile. Reconnecting widens the same account |
| Inbox | New mail and API sends appear as inbox conversations, one per Gmail thread |
| Posting and analytics | No |
| List the accounts | `GET /v1/accounts?platform=google` |

## Connect an account

`scopes` is required. Ask only for what your product uses: Google lists every permission on the consent screen, and Workspace admins review apps by what they request.

```bash cURL
curl "https://api.pozy.ai/v1/connect/google?profileId=6710c3a9e4b0d2f1a8c95e21&scopes=email,calendar&redirect_url=https://app.example.com/connected" \
  -H "Authorization: Bearer $POZY_API_KEY"
```
```ts Node
const res = await fetch("https://api.pozy.ai/v1/connect/google?profileId=6710c3a9e4b0d2f1a8c95e21&scopes=email,calendar&redirect_url=https:%2F%2Fapp.example.com%2Fconnected", {
  headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` },
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
// Redirect the user to data.authUrl
```
```python Python
import os, requests

resp = requests.get(
    "https://api.pozy.ai/v1/connect/google",
    headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"},
    params={
        "profileId": "6710c3a9e4b0d2f1a8c95e21",
        "scopes": "email,calendar",
        "redirect_url": "https://app.example.com/connected",
    },
)
auth_url = resp.json()["authUrl"]
```

The user picks a Google account and approves. They return to your `redirect_url` with `connected=google`, `profileId`, `accountId` and `username` (the mailbox address). From the API there is no Pozy screen in between: your app picks the areas and the user sees only Google.

What counts is what Google grants, not what you asked for. A permission the user unchecks on Google's screen leaves its area out, and calls in that area answer `412 account_scope_missing`. If the user unchecks all of them, Pozy saves no account and sends your redirect `error=no_access_granted`.

## Add areas later

Connect the same Google account again with the longer list. Google keeps what it granted before and Pozy updates the same account. Pass `reconnectAccountId` to make sure the reconnect lands on that account, and `loginHint` to preselect the mailbox:

```bash cURL
curl "https://api.pozy.ai/v1/connect/google?profileId=6710c3a9e4b0d2f1a8c95e21&scopes=email,calendar,files&reconnectAccountId=6710c501e4b0d2f1a8c95e77&loginHint=ana@example.com" \
  -H "Authorization: Bearer $POZY_API_KEY"
```

A reconnect can add areas but never takes one away, even if you leave it out. To remove an area, disconnect the account, then connect it again asking only for the areas you still need.

## Gmail limits

| What | Limit |
| --- | --- |
| Daily sends from a personal Gmail mailbox | 200 |
| Daily sends from a Google Workspace mailbox | 500 |
| When the daily count restarts | 00:00 UTC |
| One body sent to over 50 recipients in an hour | A pause of 10 minutes, twice as long on each repeat up to 24 hours, with an `account.rate_limited` event |
| Addresses per field | 100 in `to`, 100 in `cc`, 100 in `bcc` |
| Attachments | 20 files, 25 MB combined |

A reply uses up a send, and so does a message Gmail refuses. Read your remaining budget from the `X-Pozy-Send-Limit`, `X-Pozy-Send-Remaining` and `X-Pozy-Send-Reset` headers on each send, or from `sendCap` in `GET /v1/accounts/{accountId}/health`. Use the mailbox for personal mail; newsletters belong elsewhere.

## Drive access

Pozy connects Drive with `drive.file`, never full Drive access. It sees two kinds of files: files created through Pozy (`POST /v1/files`, `POST /v1/files/folders`) and files the user picked in Google Picker. Everything else stays invisible and returns `404` with `details.reason: "file_not_granted"`. [Picker sessions](https://pozy.ai/docs/productivity/files#ask-the-user-to-choose-drive-files), which let the user grant an existing file, are not available yet, so for now Pozy works with the files it created. Once they open, a pick is a lasting grant until the user removes the app from their Google account.

Google's own formats are not stored as files, so Pozy exports them on download: Docs and Slides become PDF, Sheets become XLSX and Drawings become PNG. Forms and Sites cannot be downloaded at all and say `downloadable: false`. Going the other way, `convertTo` on an upload turns the file into a Google format.

## Contacts sync

With the `contacts` area, Pozy copies Google Contacts into the profile's [contacts](https://pozy.ai/docs/inbox/contacts) right after connecting, then once a day for changes only. There is no on-demand trigger. List them with `GET /v1/contacts?profileId=...&platform=google`.

- People without an email address are skipped. Only the first address is used.
- A person already known from another channel (WhatsApp, SMS...) with the same email gets a Google email channel instead of a second contact.
- The sync writes name, company, photo and email, and never touches tags, custom fields or other channels.
- Nothing is written back to Google. Deleting a contact in Google removes its Google channel and fires `contact.deleted`; the contact itself stays.

## Push notifications

When the account connects, Pozy subscribes to Google's push notifications for the granted areas and keeps them renewed (Google channels expire within seven days; Pozy renews any that expire within 24 hours). You only subscribe your own [webhook](https://pozy.ai/docs/webhooks) to the events you want.

| Event | Area | Fires when |
| --- | --- | --- |
| `message.received`, `message.sent`, `conversation.started` | `email` | Mail arrives in the inbox, is sent through the API, or starts a new thread |
| `calendar.event.created`, `.updated`, `.deleted` | `calendar` | Someone adds, edits or removes an event in the primary calendar |
| `file.created`, `file.updated`, `file.deleted` | `files` | A Drive file Pozy has access to is added, edited or removed |
| `file.picked` | `files` | The user is done choosing files in the picker |
| `contact.updated`, `contact.deleted` | `contacts` | The daily sync changes a contact (up to a day after the change) |
| `account.rate_limited` | `email` | The mailbox enters a sending cooldown |

The `push` field of `GET /v1/accounts/{accountId}/health` is `active`; `pending`, which Pozy retries within six hours; `failed`, after three failed renewals, retried once a day; or `not_applicable`. Expect Google to repeat a change now and then, and deduplicate on the object's `id`. When Gmail has lost the history between two notifications, Pozy picks up from the present, so fetch anything you missed with `GET /v1/email/messages`.

## Errors

| Error | Cause | Fix |
| --- | --- | --- |
| `400 invalid_field_value`, `param: scopes` | `scopes` missing, empty or unknown | Pass a subset of `email`, `calendar`, `files`, `contacts` |
| `error=oauth_denied` on the redirect | The user said no, or Google turned the app down. With `admin_policy_enforced`, a Workspace admin has blocked it | Try the connect again, or have the admin allow the app |
| `401 reconnect_required` | Google no longer accepts the account's access | Have the user reconnect |
| `412 account_scope_missing` | The account lacks the area. `details` has `grantedAreas` and `missingArea` | Reconnect with the area added |
| `429 email_send_cap_reached` | Today's cap is used. `details.cap` has `limit`, `used`, `resetsAt` | Wait for `resetsAt` |
| `429 rate_limited` | A sending cooldown, or Google throttled the mailbox | Wait for `Retry-After` |
| `502 platform_api_error` | Google returned an unmapped error | Retry. The message carries Google's |

If Google will not renew access, for instance because the user removed the app in their Google account settings, Pozy marks the account disconnected and sends `account.disconnected`.

## Endpoints

| Endpoint | What it does |
| --- | --- |
| [`GET /v1/connect/{platform}`](https://pozy.ai/docs/api/getConnectUrl) | Get OAuth connect URL |
| [`GET /v1/accounts/{accountId}/health`](https://pozy.ai/docs/api/getAccountHealth) | Check account health |

| Endpoint | What it does |
| --- | --- |
| [`GET /v1/calendar/calendars`](https://pozy.ai/docs/api/listCalendars) | List calendars |
| [`GET /v1/calendar/events`](https://pozy.ai/docs/api/listCalendarEvents) | List events in a window |
| [`POST /v1/calendar/events`](https://pozy.ai/docs/api/createCalendarEvent) | Create an event |
| [`GET /v1/calendar/events/{eventId}`](https://pozy.ai/docs/api/getCalendarEvent) | Get an event |
| [`PATCH /v1/calendar/events/{eventId}`](https://pozy.ai/docs/api/updateCalendarEvent) | Update an event |
| [`DELETE /v1/calendar/events/{eventId}`](https://pozy.ai/docs/api/deleteCalendarEvent) | Delete an event |
| [`POST /v1/calendar/events/{eventId}/rsvp`](https://pozy.ai/docs/api/rsvpCalendarEvent) | Respond to an invitation |
| [`POST /v1/calendar/free-busy`](https://pozy.ai/docs/api/calendarFreeBusy) | Busy blocks for one or more accounts |
| [`POST /v1/calendar/availability`](https://pozy.ai/docs/api/calendarAvailability) | Find meeting slots across accounts |
| [`GET /v1/email/threads`](https://pozy.ai/docs/api/listEmailThreads) | List email threads |
| [`GET /v1/email/threads/{threadId}`](https://pozy.ai/docs/api/getEmailThread) | Get an email thread with its messages |
| [`PATCH /v1/email/threads/{threadId}`](https://pozy.ai/docs/api/updateEmailThread) | Update every message in a thread |
| [`GET /v1/email/messages`](https://pozy.ai/docs/api/listEmailMessages) | List email messages |
| [`POST /v1/email/messages`](https://pozy.ai/docs/api/sendEmailMessage) | Send an email |
| [`GET /v1/email/messages/{messageId}`](https://pozy.ai/docs/api/getEmailMessage) | Get an email message |
| [`PATCH /v1/email/messages/{messageId}`](https://pozy.ai/docs/api/updateEmailMessage) | Mark read or starred, or move a message |
| [`DELETE /v1/email/messages/{messageId}`](https://pozy.ai/docs/api/trashEmailMessage) | Move a message to trash |
| [`POST /v1/email/messages/{messageId}/reply`](https://pozy.ai/docs/api/replyEmailMessage) | Reply to a message |
| [`GET /v1/email/folders`](https://pozy.ai/docs/api/listEmailFolders) | List mailbox folders |
| [`GET /v1/email/attachments/{attachmentId}`](https://pozy.ai/docs/api/downloadEmailAttachment) | Download an attachment |
| [`GET /v1/files`](https://pozy.ai/docs/api/listFiles) | List files in a folder or search the drive |
| [`POST /v1/files`](https://pozy.ai/docs/api/uploadFile) | Upload a file |
| [`POST /v1/files/folders`](https://pozy.ai/docs/api/createFolder) | Create a folder |
| [`POST /v1/files/picker-sessions`](https://pozy.ai/docs/api/createFilePickerSession) | Create a hosted Google Picker session (not available yet) |
| [`GET /v1/files/{fileId}`](https://pozy.ai/docs/api/getFile) | Get a file or folder |
| [`PATCH /v1/files/{fileId}`](https://pozy.ai/docs/api/updateFile) | Rename or move a file |
| [`DELETE /v1/files/{fileId}`](https://pozy.ai/docs/api/trashFile) | Move a file to the trash |
| [`GET /v1/files/{fileId}/download`](https://pozy.ai/docs/api/downloadFile) | Download a file |
| [`GET /v1/files/{fileId}/share-link`](https://pozy.ai/docs/api/getFileShareLink) | Get an anyone-with-the-link share link |
