Skip to content
Go to app

Events and recurrence

Open in ChatGPT

An event is a single event or a whole series. You address it by your own external_id, for example the post ID from WordPress. You don’t need to store any id from us.

PUT /v1/publisher/calendars/{calendar_id}/events/{external_id}
Terminal window
curl -X PUT https://api.kalender-sync.de/v1/publisher/calendars/$KS_CAL/events/wp-1482 \
-H "Authorization: Bearer $KS_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Sunset Yoga in the Park",
"description": "<p>Bring your own <strong>mat</strong>. Free for members.</p>",
"location": "Riverside Park, Pier 2, New York",
"url": "https://example.org/classes/sunset-yoga/",
"start": "2026-11-12T18:00:00",
"end": "2026-11-12T19:30:00",
"timezone": "America/New_York",
"status": "confirmed",
"categories": ["Outdoor"],
"metadata": { "post_id": 1482 }
}'

external_id may be 1 to 128 characters: letters, digits and . _ : -. A prefix such as wp-1482 works well.

Response: 201 Created the first time, 200 OK on a change.

{
"external_id": "wp-1482",
"calendar_id": "cal_XXXXXXXXXXXX",
"sequence": 0,
"created_at": "2026-11-03T14:05:40.000Z",
"updated_at": "2026-11-03T14:05:40.000Z",
"event": {
"title": "Sunset Yoga in the Park",
"all_day": false,
"start": "2026-11-12T18:00:00",
"end": "2026-11-12T19:30:00",
"timezone": "America/New_York",
"status": "confirmed",
"show_as": "free",
"categories": ["Outdoor"],
"description": "Bring your own mat. Free for members.",
"location": "Riverside Park, Pier 2, New York",
"url": "https://example.org/classes/sunset-yoga/",
"metadata": { "post_id": 1482 }
},
"changed": true
}
  • event is the event as we stored it: times as local time in timezone, HTML as text.
  • sequence counts up with every real change.
  • Send the same event again and the API answers 200 with "changed": false. Nothing is written, the feed stays unchanged. So you can simply call PUT on every save in your system.
  • PUT replaces the whole event. Fields you leave out are empty or back to their default afterwards.

Optionally send the last sequence you saw:

If-Match: "3"

If the event changed in the meantime (or does not exist yet), you get 409 sequence_mismatch instead of an overwrite.

FieldRulesDefault
titleRequired, 1 to 255 characters
startRequired. Date or date-time, see times
endOptional. For timed events not before start; for all-day events exclusivestart + the calendar’s default_duration, or one day
timezoneIANA name, e.g. America/New_YorkThe calendar’s time zone
all_daytrue or falsetrue if start is a plain date
descriptionText or HTML, up to 65,536 characters. HTML becomes text; links are kept as text (URL). At most 8,000 characters are stored.
locationup to 500 characters
urlhttps:// URL, up to 2,000 characters. Shown as a link in apps and also appended to the description.
statusconfirmed, tentative or cancelledconfirmed
show_asfree or busy: whether the event counts as busy for subscribersfree
categoriesup to 10 entries of 1 to 50 characters[]
recurrenceSeries, see recurrence
metadataAny JSON object up to 2 KB. Returned in API responses, but never shown in the feed or on the subscribe page.

show_as defaults to free: public events should not mark your subscribers’ calendars as busy.

A cancelled event ("status": "cancelled") stays in the feed and is marked as cancelled there; how that looks depends on the app. To remove it completely, delete it.

start / endMeaning
"2026-11-12T18:00:00"Local time in timezone (or the calendar’s time zone). Recommended.
"2026-11-12T23:00:00Z" or "2026-11-12T18:00:00-05:00"Instant with offset. Converted to timezone and stored that way.
"2026-11-12"Plain date: all-day event.

Local time plus an IANA time zone is the safest choice: daylight saving time stays right, even for series across the clock change.

All-day events: end is exclusive. An event on 12 November only has "start": "2026-11-12" and "end": "2026-11-13", or no end at all. A three-day festival from 12 to 14 November has "end": "2026-11-15".

{
"title": "Morning Flow",
"start": "2026-11-03T07:30:00",
"end": "2026-11-03T08:30:00",
"timezone": "America/New_York",
"recurrence": {
"rules": ["FREQ=WEEKLY;BYDAY=TU;UNTIL=20270330"],
"except_dates": ["2026-12-22", "2026-12-29"],
"overrides": [
{
"original_start": "2026-11-17T07:30:00",
"start": "2026-11-17T08:00:00",
"end": "2026-11-17T09:00:00",
"location": "Studio 2"
},
{ "original_start": "2026-12-01T07:30:00", "status": "cancelled" }
]
}
}
FieldRules
rules1 to 5 RRULE strings per RFC 5545, up to 500 characters each. The RRULE: prefix is optional. Several rules become separate series in the feed.
except_datesup to 1,000 occurrences that are skipped without a trace. A date is enough; for timed series it means the occurrence on that day.
overridesup to 500 changed occurrences, see below.

Allowed RRULE parts: FREQ (DAILY, WEEKLY, MONTHLY, YEARLY), INTERVAL (1 to 1,000), COUNT (1 to 1,000), UNTIL, BYDAY, BYMONTHDAY, BYMONTH, BYSETPOS, WKST. COUNT and UNTIL exclude each other. Other parts such as BYHOUR or FREQ=HOURLY are refused with unsupported_rrule.

UNTIL may be YYYYMMDD, YYYYMMDDTHHMMSS (local time in the event’s time zone) or YYYYMMDDTHHMMSSZ (UTC). We convert it the way RFC 5545 requires. A plain date on a timed event means “through the end of that day”.

  • original_start names the occurrence as the rule generates it, in the event’s time zone (a date for all-day series).
  • You can change start, end, title, description, location, url and status. Fields you leave out keep the series value.
  • "status": "cancelled" cancels just that occurrence. To remove it without a trace, use except_dates instead.
  • Overrides are part of the event: every PUT sends the full list. An override you leave out reverts to the series.
  • Each occurrence may be overridden only once (otherwise duplicate_override).

How common fields of an event plugin (WordPress and similar) map:

In the pluginIn the API
Post IDexternal_id, e.g. "wp-1482"
Titletitle
Content / description (HTML)description. HTML works as is; a short excerpt plus url works better.
Event permalinkurl
Venue, addresslocation, e.g. "Studio by the River, 5 Main Street, New York"
Start date + start timestart: "2026-11-12T18:00:00"
End date + end timeend: "2026-11-12T19:30:00"
Site time zone (timezone_string)timezone on the calendar, on the event only if different
“All day” checkedstart/end as plain dates, end + 1 day
Categories, tagscategories
Status “cancelled”"status": "cancelled"
Status “tentative”"status": "tentative"
Post trashed, draft, privateDELETE on the event
Custom fields (price, ticket id …)metadata (only for you) or as text in description (for subscribers)

Recurrence settings:

Setting in the pluginRRULE
Daily / weekly / monthly / yearlyFREQ=DAILY / WEEKLY / MONTHLY / YEARLY
Every 2 weeksFREQ=WEEKLY;INTERVAL=2
Tuesdays and ThursdaysFREQ=WEEKLY;BYDAY=TU,TH
Second Monday of the monthFREQ=MONTHLY;BYDAY=2MO
Last Friday of the monthFREQ=MONTHLY;BYDAY=-1FR
On the 15th of every monthFREQ=MONTHLY;BYMONTHDAY=15
Ends after 10 occurrences;COUNT=10
Ends on 25 March 2027;UNTIL=20270325
Blackout dates (holidays)recurrence.except_dates
Single occurrence movedrecurrence.overrides

For PHP, the MIT-licensed library rlanvin/php-rrule builds and validates these rules.

Terminal window
curl https://api.kalender-sync.de/v1/publisher/calendars/$KS_CAL/events/wp-1482 -H "Authorization: Bearer $KS_KEY"

If the event does not exist: 404 event_not_found.

Terminal window
curl "https://api.kalender-sync.de/v1/publisher/calendars/$KS_CAL/events?limit=100" -H "Authorization: Bearer $KS_KEY"
ParameterMeaning
limit1 to 100, default 100
cursornext_cursor of the previous page
updated_sinceISO instant with offset, e.g. 2026-11-01T00:00:00Z: only events changed after it

The response contains data (events as above) and next_cursor (null on the last page). Events are sorted by last change, so updated_since plus the cursor returns every change exactly once. An invalid cursor answers 400 invalid_cursor.

Terminal window
curl -X DELETE https://api.kalender-sync.de/v1/publisher/calendars/$KS_CAL/events/wp-1482 -H "Authorization: Bearer $KS_KEY"

Always 204 No Content, also when the event was already gone. Retries are safe.

Invalid events are refused as a whole, with 400 invalid_request and every problem at once in errors:

{
"type": "https://docs.kalender-sync.de/en/api/errors/invalid_request",
"title": "The request is invalid",
"status": 400,
"code": "invalid_request",
"request_id": "req_…",
"errors": [
{ "field": "end", "code": "end_before_start", "message": "end must not be before start." },
{ "field": "recurrence.rules[0]", "code": "unsupported_rrule", "message": "BYHOUR is not supported." }
]
}
errors[].codeMeaning
invalid_timezoneUnknown time zone
invalid_dateUnreadable date (all-day events, except_dates)
invalid_datetimeUnreadable date-time, or an override that does not fit the series
end_before_startend is before start (all-day: not after it)
invalid_rruleRule is malformed, e.g. COUNT and UNTIL together
unsupported_rruleRule part or frequency is not supported
duplicate_overrideAn occurrence was overridden twice
too_largemetadata larger than 2 KB

Schema violations (missing required field, unknown field, text too long) carry the validator’s code, e.g. invalid_type, too_big or unrecognized_keys.

Other possible errors when writing: event_limit_reached, calendar_not_found, plan_required, calendar_blocked.