---
title: "Event Series API"
description: "Make several dates from one floor plan or from one event you set up, each a normal event sold on its own, and add dates later with the same seats and prices."
---

An event series is a run of dates from one floor plan: a play every night for
three weeks, a matinee and an evening each Saturday, a cinema's showtimes. Each
date is a normal event with its own key. You sell it, report on it and check it
in exactly like any other event, through the [Events API](/server-api/events/).

If the dates must be bought together, use a
[Performance Group](/server-api/performance-groups/) instead. If buyers hold a
seat for a whole season and renew it, use [Seasons](/server-api/seasons/).

Every route on this page takes a matching `sk_test_…` or `sk_live_…` secret
key. A test key makes test events; a live key makes live events, which use
credits like any other live event.

A key can't make a date public. The paste-in
[run tag](/buyer-sdk/whats-on/#a-run-of-dates) lists public dates that sell
through SeatLayer checkout, so make the dates public from the dashboard first.

## How the dates are planned

You send a schedule, not a list of timestamps. SeatLayer plans the dates with
the same engine the dashboard's **New event** sheet uses, so a series made by
API and one made in the dashboard have the same dates.

| Field | Meaning |
|---|---|
| `rule` | `none` (one day), `daily`, `weekly`, `biweekly`, or `dates` (the days in `dates`) |
| `from` | The first day, `YYYY-MM-DD`. Not used by `dates` |
| `until` or `count` | The last day (inclusive), or how many days. With neither, one day |
| `times` | One to six start times, `HH:MM`. Each day gets one date per time |
| `skip` | Moments to leave out, `YYYY-MM-DDTHH:MM`: "Sunday has no evening show" |
| `dates` | For `rule: dates`: the days, `YYYY-MM-DD` |

Times are read in the series' `timezone` (an IANA name such as
`Europe/London`). A start time stays the same on the clock across a daylight
saving change: a 19:30 show is at 19:30 in October and in November. Without a
`timezone`, the usual zone of this floor plan's events is used, else UTC.

Every date carries the series name as its name, `Hamlet`, with nothing added.
The dates are told apart by `startsAt` and linked by `seriesKey`, so a ticket
page can show one title with its times. For that reason a series name cannot
hold `{n}`, `{date}` or `{time}`.

A series holds at most **366 dates**. One call adds at most **100**.

## Preview, then create

<ApiEndpoint method="POST" path="/v1/event-series" auth="Secret key" />

Send `dryRun: true` to see the dates without creating anything:

```bash
curl https://api.seatlayer.io/v1/event-series \
  -H "Authorization: Bearer sk_test_…" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Hamlet",
    "chartKey": "c_41b2…",
    "timezone": "Europe/London",
    "schedule": { "rule": "daily", "from": "2027-03-01", "until": "2027-03-21", "times": ["14:30", "19:30"] },
    "dryRun": true
  }'
```

```json
{
  "timezone": "Europe/London",
  "dates": [
    { "moment": "2027-03-01T14:30", "startsAt": 1803911400000, "name": "Hamlet" },
    { "moment": "2027-03-01T19:30", "startsAt": 1803929400000, "name": "Hamlet" }
  ]
}
```

Without `dryRun`, the series is created and a job makes its dates. The answer is
`202` straight away; send an `Idempotency-Key` so a retry gets the same series:

```json
{
  "series": { "seriesKey": "ser_7c1d…", "name": "Hamlet", "state": "creating", "dateCount": 0, "job": { "jobId": "ser_7c1d…:create", "state": "running", "total": 42, "done": 0, "failed": 0 } },
  "jobId": "ser_7c1d…:create"
}
```

The first date is made from the published floor plan, as
`POST /v1/events` makes an event. Every later date is a copy of the first:
the same seats, the same event-owned prices, releases, booking form, online
codes and blocked seats. Optional fields: `venue`, and `durationMinutes` to set
each date's end.

When the job ends, SeatLayer sends [`series.created`](/webhooks/events/#event-series)
with the event keys it made and any date it could not make. Each date also
sends `event.created` as it is made.

| Error | When |
|---|---|
| `404 chart_not_found` | No such floor plan in this organization |
| `422 chart_not_published` | Publish the floor plan first |
| `422 invalid_schedule` | The schedule is not valid; `detail` names the field |
| `422 too_many_dates` | The schedule makes more than 366 dates |
| `422 invalid_name` | Empty, over 120 characters, or holds `{n}`, `{date}` or `{time}` |

## From an event you already set up

Send `fromEventKey` instead of `name` and `chartKey` to turn one event into a
run. The event becomes the first date, under its own key, and every other date
is a copy of it: its floor plan, prices, checkout, payment account, poster,
booking form, online codes and its own blocked seats. A copy carries none of the
event's orders, holds, sold seats, reservations or tickets, and gets its own
key. This is how the dashboard's **New event** sheet makes several dates: it
sets up the first one, then asks for the rest.

```json
{ "fromEventKey": "lighthouse-keeper", "schedule": { "rule": "daily", "from": "2026-11-06", "until": "2026-11-19", "times": ["14:00", "17:30", "20:15"] } }
```

The series takes the event's name, zone and floor plan. A moment in the
schedule equal to the event's own start is the event itself, so it is not made
again. `name` and `timezone` may be sent only when they match the event;
`chartKey`, `venue` and `durationMinutes` come from the event and are refused.
A `dryRun` lists every date the series would hold, the event included.
[`series.created`](/webhooks/events/#event-series) lists the event first.

| Error | When |
|---|---|
| `404 event_not_found` | No such event in this organization and mode |
| `422 invalid_request` | A field the event already sets was sent with another value |
| `422 event_has_no_date` | The event has no start time yet |
| `422 event_in_season` | The event is a season date |
| `422 event_in_performance_group` | The event is in an active performance run |
| `422 event_in_series` | The event is already a date of a series, named by `seriesKey` (also while that series is still being made); [add dates](#add-dates) to it instead |
| `422 invalid_schedule` | The schedule adds no date to the event |
| `503 series_not_started` | The series did not answer, so whether it started is not known. The event stays with it: send the same request again, with the same `Idempotency-Key`, to carry on. A request with a new key finds that series, or makes a new one if the first never started |
| `409 create_abandoned` | This request's series never started and was given up when another request came for the event. Its key answers this every time: send the request again with a new `Idempotency-Key` |

## Follow the job

<ApiEndpoint method="GET" path="/v1/event-series/:key" auth="Secret key" />

Answers the series, every date made so far in start order (each as
[`retrieveEvent`](/server-api/events/) describes it), and the latest job:

```json
{
  "series": { "seriesKey": "ser_7c1d…", "state": "ready", "dateCount": 42, "firstEventKey": "ev_3a9e…" },
  "events": [{ "key": "ev_3a9e…", "name": "Hamlet", "startsAt": 1803911400000, "seriesKey": "ser_7c1d…" }],
  "job": { "jobId": "ser_7c1d…:create", "state": "completed", "total": 42, "done": 42, "failed": 0, "failedDates": [] }
}
```

`state` is `creating` while a job runs, then `ready`, or `partial` when some
dates could not be made. `job.failedDates` names each one and why, for example
`insufficient_credits`: every date of the series not made, whichever job it
failed in, so a later job does not hide it. Add a failed date again, or send
`{ "retryFailed": true }` to [add dates](#add-dates), to make it again. The job is durable: if SeatLayer restarts part way, it
carries on where it stopped, and no date is made twice.

To list the dates as events, use `GET /v1/events?seriesKey=ser_7c1d…`. Every
event of a series carries `seriesKey`.

<ApiEndpoint method="GET" path="/v1/event-series" auth="Secret key" />

Lists the series in the key's mode, newest first (`limit` up to 100, `cursor`
for the next page).

## Add dates

<ApiEndpoint method="POST" path="/v1/event-series/:key/dates" auth="Secret key" />

Send either a `schedule` (as above) or exact moments:

```json
{ "dates": ["2027-03-28T19:30", "2027-04-04T19:30"] }
```

Each new date is a copy of the series' first date, so a price you changed on it
is the price the new dates get. A moment the series already holds is left out;
one that failed to be made is made again. `{ "retryFailed": true }` takes
every date that failed (up to 100). The answer is `202 {jobId, added}`, and
[`series.dates_added`](/webhooks/events/#event-series) says when the job ends.
The same `Idempotency-Key` answers the same job, even when its first answer was
lost after the job started. The 366-date cap holds when two calls add dates
at once.

| Error | When |
|---|---|
| `404 series_not_found` | No such series for this key and mode |
| `409 series_busy` | A change to every date is running; add the dates once it ends |
| `422 too_many_dates` | More than 100 new dates, or the series would pass 366 |
| `422 invalid_schedule` | Not valid, or every date is already in the series |
| `422 nothing_to_retry` | `retryFailed`, and no date of the series failed |

## Change all dates

<ApiEndpoint method="PATCH" path="/v1/event-series/:key" auth="Secret key" />

Change every upcoming date at once. Send any of these:

| Field | What every upcoming date gets |
|---|---|
| `name` | The new series name: every upcoming date named `Hamlet` becomes `Hamlet Live` |
| `prices` | `{categories}`: only the categories, or a tiered category's tiers, whose price changes, each shaped as [`PUT /v1/events/:key/pricing`](/server-api/prices/) takes it. Every other price stays each date's own |
| `sales` | `on` opens sales, `off` pauses them |

```json
{ "name": "Hamlet Live", "prices": { "categories": [{ "key": "std", "price": 42 }] }, "sales": "off" }
```

Dates already over are left alone. New prices apply to new orders; tickets
already sold keep the price they were sold at. A price you leave out keeps
each date's own value, so a run whose weekend dates cost more than its
weekdays keeps the difference. A date you renamed on its own
keeps its name, but still takes the prices and sales. Opening sales runs each
date's own go-live checks: a date that cannot open is listed in
`job.failedDates` with `sales_not_ready`, and the rest open. Anything else
stays per date, through the [Events API](/server-api/events/).

A job makes the change, one date at a time. The answer is `200` when it ended
within the call, else `202`; follow it with `GET /v1/event-series/:key`:

```json
{
  "series": { "seriesKey": "ser_7c1d…", "name": "Hamlet Live", "state": "ready" },
  "jobId": "sju_5e0a…",
  "job": { "jobId": "sju_5e0a…", "state": "running", "total": 41, "done": 12, "skipped": 0, "failed": 0 }
}
```

The job is durable: a change that SeatLayer had to restart part way carries on,
and no date is changed twice. Send an `Idempotency-Key`, so a retry answers the
same job.

| Error | When |
|---|---|
| `409 series_busy` | Dates are still being made, or another change is running |
| `422 nothing_to_change` | No `name`, `prices` or `sales` was sent |
| `422 invalid_name` | Empty, over 120 characters, or holds `{n}`, `{date}` or `{time}` |
| `422 invalid_sales` | `sales` is not `on` or `off` |
| `422 invalid_event_pricing` | No category was sent, one is not on the floor plan or is sent twice, a tier is unknown, a tiered category is given one `price`, or a price is not valid |