# Email, video, the API and webhooks

> playcode.email.send(), playcode.media.video(), playcode.api for the Playcode API, and verifyWebhook() for the deliveries Playcode sends your app.
> Source: https://playcode.io/docs/developers/sdk-email-video-api - last reviewed 2026-10-17.

Besides AI, the SDK sends your app's email, renders video, calls the Playcode API, and checks the webhook deliveries Playcode sends your app. Email and video use the app's `PLAYCODE_SECRET_KEY`, `playcode.api` uses an access token in `PLAYCODE_TOKEN`, and `verifyWebhook()` takes the secret you pass it. The client reads each key when a call needs it.

## Email

```ts
const { status, messageId } = await playcode.email.send({
  to: 'anna@example.com',
  subject: 'Your invoice',
  html: '<p>Thank you!</p>',
  replyTo: 'support@example.com',
  idempotencyKey: invoice.id,
})
```

| Field | What it does |
|---|---|
| `to` | One address, or a list of up to 10. Leave it out to email the project owner's account address |
| `subject` | The subject line |
| `html`, `text` | The body: at least one of them |
| `from` | The sender. An address at your verified domain is used as written; otherwise the email goes out from the app's `playcode.email` address, with this name on it |
| `replyTo` | Where replies go |
| `headers` | Stored with the email, not sent |
| `idempotencyKey` | Sending again with the same key returns the first email instead of a second one |

The answer's `status` is `sending`, `held`, `captured` or `withheld`: [Send email from your app](/docs/add-to-your-app/send-email) explains each one. A retry with the same `idempotencyKey` returns the email as it is now, which can also be `delivered`, `bounced` or `complained`. New values can appear, so treat an unknown one as not delivered yet.

Email to your customers is `held` until your app sends from your own verified domain, and a workspace without a Pro or Max plan delivers none: see [Who receives your app's email](/docs/add-to-your-app/send-email#who-receives-your-apps-email).

A request waits 10 seconds by default (`timeoutMs`). The SDK never retries a send by itself: with an `idempotencyKey`, your own retry is safe; without one, a retry after a timeout can send twice. `import { sendEmail } from '@playcode/sdk/email'` gives the same call without the client.

## Video and images

```ts
const job = await playcode.media.video({
  prompt: 'A paper boat on a lake at dawn',
  quality: 'draft',
  idempotencyKey: id,
})
const { url } = await job.wait()
```

`playcode.media.video()` starts a render and returns a job. `job.wait()` waits until the video is ready, 15 minutes at most by default; `job.get()` reads its state, and `job.cancel()` stops it. `quality` is `draft` (a fast preview), `standard` (the default) or `high`, each with its own price per second. With an `idempotencyKey`, a retry returns the same job instead of paying twice. The result is an address on Playcode, and each second of video is paid from the workspace's credits. The app's key needs the media service: ask Playcode's agent to switch on media for the app.

`playcode.media.image()` is in the SDK too, but your app's key cannot use it yet.

## The Playcode API

```ts
import { Playcode } from '@playcode/sdk'

const playcode = new Playcode({ apiToken: process.env.PLAYCODE_TOKEN })

const domain = await playcode.api.domainGet({ domain: 'example.com' })
if (domain.uid) {
  const input = { autoRenew: true }
  await playcode.api.domainUpdate({ uid: domain.uid, input })
}
```

`playcode.api` has one typed method for each operation of the [Playcode API](/docs/developers/api) at the SDK's release: domains, projects with their files, commands and publishes, chats, workspaces, billing, apps and more. Its credential is an access token, never the app's secret key, and what the token may do was decided when it was made: see [Access tokens and permissions](/docs/developers/tokens-and-permissions).

Each write carries an `Idempotency-Key`, and the same key rides every retry. The client retries a network error, a 429 or a 5xx at most 2 times. The key stays the same, so a retry never doubles a write. Its calls have no default timeout: pass `signal` in the options to bound one. Pass your own key as the second argument: `{ idempotencyKey }`.

## Webhooks

```ts
import { verifyWebhook } from '@playcode/sdk'

const { id, event } = verifyWebhook({
  signature: request.headers.get('playcode-signature') ?? undefined,
  body: rawBody,
  secret: process.env.PLAYCODE_WEBHOOK_SECRET!,
})
```

Pass the raw request body, exactly as it arrived: a re-encoded body does not match the signature. Playcode tries a delivery up to 8 times, so skip an `id` you already handled. `PLAYCODE_WEBHOOK_SECRET` is the endpoint's `pcwh_...` signing secret, shown once when you add the endpoint. The call is synchronous and needs no Node module. It throws `PlaycodeWebhookError` for a bad signature, or for a delivery older than 300 seconds (`toleranceSeconds` changes that). See [Webhooks](/docs/developers/webhooks).

## Limits

- Image generation from your app's own key is not available yet.
- An operation added to the Playcode API after the SDK's release needs a newer SDK.
- The tokens, webhooks, secret-key and OAuth-app operations of `playcode.api` take a signed-in person: every access token is refused there.
