# Schedule a campaign from your own system

By the end, one command puts a campaign into a program for a date range, during the hours you choose, and a second command takes it out again. The dates can come straight from your marketing calendar or shop system.

> **Type:** guide
> **For:** Developers who connect a marketing calendar, a shop system, or a campaign tool
> **Prerequisite:** a key `ak_…` of the class **Playout control**, a program that your displays play, and a playlist or scene with the campaign
> **Where:** command line

## Why one entry per day

A program entry of the type `once` runs from `startsAt` to `endsAt` without a break. A campaign from 2 to 14 November as a single entry would therefore run through every night. It would also push aside the nightly broadcast pause, because a one-off entry beats every recurring one. The script instead creates one one-off entry per day, from opening to closing, and gives them all the same label so that it can find and remove them later.

| Rule | Effect on the campaign |
|---|---|
| A one-off entry beats a recurring one | The campaign wins over the weekly plan during its hours. |
| The shorter window beats the longer one | A short promotion inside the campaign hours still wins over it. |
| If both are equal, the entry created last wins | Two campaigns with the same hours: the newer one plays. |
| Outside every entry, the base program runs | The displays return to normal after closing time. |

## Steps

1. **Find the IDs.** Open the program and the playlist in the portal: the addresses end with their IDs `sch_…` and `pll_…`.

2. **Save the script:**

   ```ts
   // campaign.ts
   // node campaign.ts add <sch_…> <label> <pll_…> <from> <to> <opens> <closes>
   // node campaign.ts remove <sch_…> <label>
   const API = process.env.SZENERA_API;
   const KEY = process.env.SZENERA_API_KEY; // class Playout control
   const TIME_ZONE = 'Europe/Berlin';

   async function call(method: string, path: string, body?: unknown, idempotencyKey?: string) {
     const headers: Record<string, string> = {
       Authorization: `Bearer ${KEY}`,
       'Content-Type': 'application/json',
     };
     if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
     const res = await fetch(`${API}${path}`, {
       method,
       headers,
       body: body === undefined ? undefined : JSON.stringify(body),
     });
     if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${await res.text()}`);
     return res.json();
   }

   // A local time on that day with the offset of the time zone, +01:00 in winter and +02:00 in summer
   function at(day: string, time: string): string {
     const zone = new Intl.DateTimeFormat('en', { timeZone: TIME_ZONE, timeZoneName: 'longOffset' })
       .formatToParts(new Date(`${day}T12:00:00Z`))
       .find((part) => part.type === 'timeZoneName')?.value ?? 'GMT';
     return `${day}T${time}:00${zone === 'GMT' ? '+00:00' : zone.slice(3)}`;
   }

   function nextDay(day: string): string {
     const date = new Date(`${day}T00:00:00Z`);
     date.setUTCDate(date.getUTCDate() + 1);
     return date.toISOString().slice(0, 10);
   }

   const [action, scheduleId, label, playlistId, from, to, opens, closes] = process.argv.slice(2);

   if (action === 'add') {
     for (let day = from; day <= to; day = nextDay(day)) {
       await call(
         'POST',
         `/v1/schedules/${scheduleId}/items`,
         {
           label,
           content: { kind: 'playlist', playlistId },
           recurrence: { kind: 'once', startsAt: at(day, opens), endsAt: at(day, closes) },
         },
         `campaign-${encodeURIComponent(label)}-${day}`,
       );
       console.log(`added ${day} ${opens} to ${closes}`);
     }
   }

   if (action === 'remove') {
     const { schedule } = await call('GET', `/v1/schedules/${scheduleId}`);
     for (const item of schedule.items.filter((entry) => entry.label === label)) {
       await call('DELETE', `/v1/schedules/${scheduleId}/items/${item.id}`);
       console.log(`removed ${item.recurrence.startsAt}`);
     }
   }
   ```

3. **Add the campaign:**

   ```bash
   node campaign.ts add sch_01KWVAFRM0ZETPAN0000000001 "Herbstaktion" \
     pll_01KWV91ZS0PRGRAMM000000001 2026-11-02 2026-11-14 08:00 20:00
   ```

   ```text
   added 2026-11-02 08:00 to 20:00
   added 2026-11-03 08:00 to 20:00
   …
   ```

   The idempotency key is built from label and day. If the script stops halfway, run it again within 24 hours: days that are already there aren't created twice.

4. **Remove it** when the campaign is canceled or over:

   ```bash
   node campaign.ts remove sch_01KWVAFRM0ZETPAN0000000001 "Herbstaktion"
   ```

Entries that lie in the past don't play, but they stay in the program until someone removes them. A program holds at most 200 entries, so remove a finished campaign by its label.

## Check the result

- The weekly plan of the program in the portal shows one block per day with the label of the campaign, see [Read the weekly plan](https://docs.szenera.com/programs/read-weekly-plan).
- During the hours of the campaign, the displays with this program play the playlist.
- After `remove`, no entry with this label is left in `GET /v1/schedules/{id}`.

## If something goes wrong

| Symptom | Meaning | Solution |
|---|---|---|
| `422 Schedule.InvalidRecurrence` | The times don't fit, for example the end lies before the start. | Give opening and closing time as `08:00` and `20:00`. |
| `422 Schedule.ItemLimitReached` | The program already holds 200 entries. | Remove finished campaigns by their label. |
| `422 Schedule.ContentNotFound` | The playlist ID doesn't exist. | Check the ID in the portal address. |
| `422 Validation.IdempotencyKeyReused` | The same label and day were sent with other hours within 24 hours. | Remove the campaign, then add it again under a new label. |
| The campaign doesn't appear on a display | Another program runs there, an override is active, or a shorter entry wins. | See [How does a display decide what to play?](https://docs.szenera.com/programs/about-programs). |

## Related

- [Show an emergency message at a location](/recipes/emergency-message)
- [Idempotency from the start](/getting-started/idempotency)
- [Commands for your organization](/cli/commands-organization)
