# Sign in to the customer portal with a link

> The portal.sessions.redeem operation: POST /api/v1/public/portal_sessions/{portalSessionId}/redeem in the REST API and the portal_sessions_redeem tool in the MCP server.

Redeems a one-time sign-in link (from the portal's email, or an operator's `portal.sessions.create`): the link's
portal session becomes an active session for 7 days, and the response carries its `sessionToken` once. The
portal keeps it in an HttpOnly, Secure, SameSite=Lax cookie on its own host; only its hash is stored.

A link works once and for 15 minutes. A used, expired or signed-out link, or a wrong token, is
`portal_link_invalid`, all alike. The customer's `portalLastLoginAt` is set.

Public: the session's own brand and mode apply (a test-mode link signs in to the test-mode portal).

## At a glance

- **REST:** `POST /api/v1/public/portal_sessions/{portalSessionId}/redeem`
- **MCP tool:** `portal_sessions_redeem`
- **Operation ID:** `portal.sessions.redeem`
- **Scope:** Brand
- **Permission:** `portal:write`
- **Risk tier:** `security`
- **Dry run:** Not supported
- **Idempotency:** `Idempotency-Key` required
- **Modes:** Test and live
- **Authentication:** None (a public endpoint, rate-limited per IP address)
- **Since:** M9

## Request

### Path parameters

| Field             | Type         | Description                          |
| ----------------- | ------------ | ------------------------------------ |
| `portalSessionId` | `psess_…` ID | The portal session's ID (`psess_…`). |

### Body

A JSON object (`Content-Type: application/json`):

| Field   | Type   | Required | Description                      |
| ------- | ------ | -------- | -------------------------------- |
| `token` | string | Yes      | The `token` of the sign-in link. |

## Response

`200 OK` with a JSON object:

| Field          | Type               | Description                                                                                    |
| -------------- | ------------------ | ---------------------------------------------------------------------------------------------- |
| `object`       | `"portal_session"` |                                                                                                |
| `id`           | `psess_…` ID       |                                                                                                |
| `customerId`   | `cus_…` ID         |                                                                                                |
| `brandId`      | `brd_…` ID         |                                                                                                |
| `brandSlug`    | string             |                                                                                                |
| `livemode`     | boolean            |                                                                                                |
| `sessionToken` | string             | The session's secret for the portal's HttpOnly cookie. Returned once; only its hash is stored. |
| `expiresAt`    | date-time          | The session ends then (7 days), or at sign-out.                                                |

## Examples

### Redeem the link from the email

```http
POST /api/v1/public/portal_sessions/psess_01m4tx5nj8emwbyf7y9vcf48b2/redeem
Idempotency-Key: 3f1c9a52-7d0e-4b8f-9a61-0c2d5e8b7f14
Content-Type: application/json

{
  "token": "Q2hhbmdlTWVJbkV4YW1wbGVzT25seS4uLi4uLi4uLi4"
}
```

Response (`200`):

```json
{
  "object": "portal_session",
  "id": "psess_01m4tx5nj8emwbyf7y9vcf48b2",
  "customerId": "cus_01m4tx5nj8emwbyf7y9vcf48b1",
  "brandId": "brd_01m4tx5nj8emwbyf7y9vcf48b0",
  "brandSlug": "genno",
  "livemode": false,
  "sessionToken": "U2Vzc2lvblRva2VuRm9yRG9jc09ubHkuLi4uLi4uLi4",
  "expiresAt": "2026-10-27T09:01:00.000Z"
}
```

## Errors

| Code                                                                                       | HTTP status | When                                                                                       | What to do                                                                                                                         |
| ------------------------------------------------------------------------------------------ | ----------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
| [`validation_failed`](https://ownrecord.io/docs/reference/errors.md#validation_failed)     | 400         | `portalSessionId` or `token` is malformed.                                                 | Fix the fields listed in `details.issues` and send the request again.                                                              |
| [`resource_not_found`](https://ownrecord.io/docs/reference/errors.md#resource_not_found)   | 404         | No portal session has this ID.                                                             | Check the ID and that your credentials are scoped to the right organization, entity, brand and mode.                               |
| [`portal_link_invalid`](https://ownrecord.io/docs/reference/errors.md#portal_link_invalid) | 401         | The link was already used, expired (15 minutes), was signed out of, or the token is wrong. | Ask for a new sign-in link on the portal's sign-in page. Links work once, for 15 minutes; an operator can also generate a new one. |
| [`customer_deleted`](https://ownrecord.io/docs/reference/errors.md#customer_deleted)       | 400         | The customer was deleted after the link was sent.                                          | Create a new customer for this buyer. Deleted customers stay readable for the records they belong to, but can't be changed.        |
| [`rate_limited`](https://ownrecord.io/docs/reference/errors.md#rate_limited)               | 429         | Too many requests from this address or client IP; wait `retry_after_ms`.                   | Wait for `retry_after_ms` and retry with backoff.                                                                                  |

It can also return these [common errors](https://ownrecord.io/docs/reference.md#common-errors): [`api_version_unsupported`](https://ownrecord.io/docs/reference/errors.md#api_version_unsupported), [`invalid_json`](https://ownrecord.io/docs/reference/errors.md#invalid_json), [`unsupported_media_type`](https://ownrecord.io/docs/reference/errors.md#unsupported_media_type), [`scope_required`](https://ownrecord.io/docs/reference/errors.md#scope_required), [`idempotency_key_required`](https://ownrecord.io/docs/reference/errors.md#idempotency_key_required), [`idempotency_key_reused`](https://ownrecord.io/docs/reference/errors.md#idempotency_key_reused), [`idempotency_in_progress`](https://ownrecord.io/docs/reference/errors.md#idempotency_in_progress), [`dry_run_not_supported`](https://ownrecord.io/docs/reference/errors.md#dry_run_not_supported), [`internal_error`](https://ownrecord.io/docs/reference/errors.md#internal_error).
