---
title: "JSON API — Events for Laravel | Roundly"
description: "An opt-in JSON API — 49 routes, request bodies, discovery filters and sorts, role-aware resources, reference resources, errors and rate limits."
url: https://roundly-consulting.com/open-source/docs/events-for-laravel/http-api
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Events for Laravel](https://roundly-consulting.com/open-source/docs/events-for-laravel.md)

# JSON API

Off until you switch it on:

```bash
EVENTS_ROUTES=on
```

- Prefix events.routes.prefix (api), route names events.\*, middleware events.routes.middleware (\['api', 'throttle:events-api'\]); write routes add events.routes.auth\_middleware (\['auth'\]).
- A switch that is not set stays off — including a published config whose routes array lacks the key, since the merge is shallow.
- Publish --tag=events-routes to edit the route file: once routes/events.php exists in your app it loads instead of the package’s, and the switch still gates it.
- Writes always need a signed-in user. After your auth\_middleware every write route runs the package’s own check: no signed-in user, or a guard user that is not an Eloquent model, is a 401 — even with auth\_middleware = \[\]. It runs before validation, so a guest never learns the shape of a request. Read routes and the invitation-link routes still allow guests.

## Conventions

- {event} is the uuid — constrained, so /events/feed.ics never reaches it — and an event the caller may not see is a 404 exactly like a missing one, on every endpoint. {rsvp} and {tier} are scoped to the event (a ticket type by id or key); {venue} is numeric.
- Your models are referenced by alias, never by class name: {type: user, id: 42} in bodies and user:42 in paths and filters (events.organizers, events.attendees). In a body every failure — an unknown alias, a malformed key, a missing row — is the same 422 unknown\_alias.
- Times in requests are wall clock in the event’s timezone or an ISO-8601 instant with an offset; responses use ISO-8601 with the event’s own offset.
- Text fields take a string (the current locale) or a locale map. Prices are decimal strings with an optional currency; responses render money-for-laravel’s MoneyResource — {minor, decimal, currency, exponent, formatted}, with minor a string and formatted for display only. A free ticket type renders price: null.
- Lists are Laravel length-aware paginators (data, links, current\_page, per\_page, total, …), with per\_page capped at events.routes.max\_per\_page.

## Routes

All 49 routes, grouped, with paths relative to the prefix. Rows marked “Guests too” also answer guests; every other route needs a signed-in user:

| Method · path | Name | Ability | Notes |
| --- | --- | --- | --- |
| `GET /events` | `events.index` | `viewAny` | Guests too. Discovery filters below → paginated EventResource. |
| `GET /events/feed.ics` | `events.feed` | — | Guests too. ?category=&label=&organizer=alias:key&venue= → text/calendar (inline); throttle events-feeds. |
| `GET /events/{event}` | `events.show` | `view` | Guests too. {data: EventResource} with hosts and viewer.can. |
| `GET /events/{event}/calendar.ics` | `events.calendar` | `view` | Guests too. text/calendar (attachment); the join link only for a viewer who may see it. |
| `GET /events/{event}/tiers` | `events.tiers.index` | `view` | Guests too. Hidden ones for managers only. |
| `GET /events/{event}/attendees` | `events.attendees.index` | `viewAttendees` | Guests too. ?status=&checked\_in=&tier=&per\_page= (default 25) — managers only for the filters. |
| `GET /events/{event}/hosts` | `events.hosts.index` | `view` | Guests too. {data: \[host reference\]}. |
| `GET /event-venues` | `events.venues.index` | — | Guests too. ?mine=1&near=lat,lng&radius=&per\_page= (default 25). |
| `GET /event-venues/{venue}` | `events.venues.show` | — | Guests too. |
| `POST /event-invitations/accept · decline` | `events.invitations.accept · decline` | the token | Guests too. {token, response?} (going default, interested) → RsvpResource (owner view) / {status: declined}; throttle events-rsvp. |
| `POST /events` | `events.store` | `create` | 201 {data: EventResource}. |
| `PATCH /events/{event} · DELETE /events/{event}` | `events.update · destroy` | `update · delete` | Only the fields sent change; ?notify=0 writes silently · 204. |
| `POST /events/{event}/restore` | `events.restore` | `restore` | Trashed events, managers only (others 404). |
| `POST /events/{event}/publish · unpublish · complete` | `events.publish · …` | `publish · …` | {data: EventResource} |
| `POST /events/{event}/postpone · cancel` | `events.postpone · cancel` | `postpone · cancel` | {reason?} (≤ 500). |
| `POST /events/{event}/reschedule` | `events.reschedule` | `reschedule` | {starts\_at, ends\_at?, reason?} |
| `POST /events/{event}/move-online` | `events.move-online` | `moveOnline` | {url, keep\_venue?} |
| `PUT · DELETE /events/{event}/poster` | `events.poster.update · destroy` | `update` | Multipart poster or a draft token · removes it. |
| `POST /events/{event}/tiers · tiers/reorder` | `events.tiers.store · reorder` | `manageTiers` | 201 AdmissionTierResource · {order: \[tier id, …\]} → 204. |
| `PATCH · DELETE /events/{event}/tiers/{tier}` | `events.tiers.update · destroy` | `manageTiers` | A tier by id or key; only the fields sent · 204. |
| `GET /events/{event}/rsvp · rsvp/qr.svg` | `events.rsvp.show · qr` | — | Your own RSVP (owner view) or null · your code as image/svg+xml, Cache-Control: no-store, private. |
| `PUT · DELETE /events/{event}/rsvp` | `events.rsvp.update · destroy` | `rsvp` | {response, guests?, tier?, note?} · answers not\_going; throttle events-rsvp. |
| `POST /events/{event}/attendees` | `events.attendees.store` | `manageAttendees` | {attendee: {type, id}, tier?, guests?, over\_capacity?} → 201 |
| `PATCH · DELETE /events/{event}/attendees/{rsvp}` | `events.attendees.update · destroy` | `manageAttendees` | {tier?, guests?} — both or neither applies · 204. |
| `POST · DELETE /events/{event}/attendees/{rsvp}/check-in` | `events.attendees.check-in · undo-check-in` | `checkIn` | Manual check-in {guests?} (no code) · undo. |
| `POST /events/{event}/check-ins` | `events.check-ins.store` | `checkIn` | A door scan {code, guests?} → CheckInResultResource, 201 first, 200 repeat; throttle events-check-in. |
| `GET /events/{event}/waitlist · POST …/waitlist/promote` | `events.waitlist.index · promote` | `viewWaitlist · manageAttendees` | In promotion order · {limit?} (1–1000). |
| `POST · DELETE /events/{event}/hosts…` | `events.hosts.store · destroy` | `manageHosts` | {person: {type, id}} (an Organizer) → 201 · {host} = alias:key → 204. |
| `GET · POST · DELETE /events/{event}/invitations…` | `events.invitations.index · store · destroy` | `invite` | Tokens never rendered · {person: {type, id}, message?} (≤ 1000) → 201 · {invitee} = alias:key → 204. |
| `POST · PATCH · DELETE /event-venues…` | `events.venues.store · update · destroy` | `VenuePolicy` | Venue body below → 201 · only the fields sent · 204. |
| `POST /event-venues/{venue}/geocode` | `events.venues.geocode` | VenuePolicy geocode | Always re-geocodes (force). |

There is no payment-confirmation route: confirm from your provider’s webhook in PHP with Events::rsvps()->confirmPayment($reference).

## Creating an event

```json
{
  "title": { "en": "Jazz Night", "sk": "Jazzový večer" },
  "venue_id": 7,
  "starts_at": "2026-11-14 20:00",
  "duration": "PT3H",
  "capacity": 120,
  "max_guests": 1,
  "category": "jazz",
  "labels": ["Live", "Late night"],
  "tiers": [
    { "name": "Free entry", "key": "free" },
    { "name": "Supporter", "price": "15.00", "currency": "EUR", "capacity": 30 }
  ],
  "publish": true
}
```

| Field | Rules |
| --- | --- |
| `title` | Required; a string or a locale map, ≤ 200 characters each. |
| `description` | ≤ 20,000. |
| `organizer` | {type, id} of events.organizers — default the caller; must allow the caller. |
| `venue_id · venue` | An existing venue (any organizer may hold an event at any venue) — or an inline new venue owned by the caller: name (required), street, city, region, postal\_code, country, coordinates, timezone. |
| `online_url · attendance_mode · visibility · guest_list · timezone` | ≤ 2048 · enum values · an IANA name. |
| `starts_at · ends_at · duration · doors_open_at · all_day_from · all_day_through` | starts\_at required unless all\_day\_from; all-day dates Y-m-d. |
| `capacity · max_guests` | 0–10,000,000 · 0–1000 |
| `waitlist · waitlist_auto_promote · show_counts · publish` | Booleans; publish: true announces it in the same transaction. |
| `rsvp_opens_at · rsvp_closes_at · reminders` | Strings; ≤ 5 reminders. |
| `category · labels` | A category of the tree · ≤ 10 strings (in the organizer’s catalogue when events.tags.scope = organizer). |
| `tiers` | ≤ 50 of {name, key?, price?, currency?, capacity?, hidden?}; price as ^\\d{1,12}(\\.\\d{1,4})?$. |
| `poster_draft` | A media-library draft token. |

PATCH /events/{event} changes only the fields sent, and null clears a nullable one; category (null clears) and labels (exactly these) apply in the same transaction. A new start on an announced event is refused — use POST /reschedule. ?notify=0, or notify: false in the body, writes silently.

## Ticket types and venues

Ticket type bodies take name (required on create, ≤ 200), description (≤ 2000), key (≤ 64; derived from the name when omitted), price + currency, capacity, sale\_starts\_at, sale\_ends\_at, min\_party (1–1000), max\_party, hidden and position. Venue bodies take name (required on create), street, city, region, postal\_code, country (2 letters), formatted\_address, coordinates (lat,lng, \[lat, lng\] or {latitude, longitude}), timezone, capacity and geocode, and on create owner ({type, id}, who must allow the caller) or shared (admins and abilities.shared\_venues only). Without owner or shared, a new venue is the caller’s own. On PATCH each address field changes on its own, and ownership never changes.

## Your RSVP

PUT /api/events/{event}/rsvp with:

```json
{ "response": "going", "tier": "free", "guests": 1, "note": "Wheelchair access" }
```

returns your own RSVP in the owner view, with your check-in code and the QR URL:

```json
{
  "data": {
    "id": 42,
    "status": "going",
    "guests": 1,
    "party_size": 2,
    "tier": { "id": 1, "key": "free", "name": "Free entry" },
    "note": "Wheelchair access",
    "waitlist_position": null,
    "held_until": null,
    "amount": null,
    "checked_in_at": null,
    "checked_in_guests": null,
    "responded_at": "2026-11-02T18:12:40+01:00",
    "payment_url": null,
    "check_in_code": "EV1-…",
    "qr_url": "https://example.com/api/events/0195f2c4-7a1e-7c3b-9d2e-5b8a1f4c6e21/rsvp/qr.svg"
  }
}
```

## The guest list

GET /events/{event}/attendees lists any status for managers (?status=, default going) with the check-in record — never codes — filtered by checked\_in and tier. Everyone else who may view the list sees going attendees only, as {attendee, guests}: status=interested is a 403 for them, and status, checked\_in and tier are ignored — the whole list, never one narrowed to who is at the venue or who holds a hidden ticket type.

## Discovery: GET /events

Built on the model’s scopes, so PHP and HTTP filter identically; query-builder splits every value on commas. The base is browse($viewer) — visible, listed, announced:

| Parameter | Scope |
| --- | --- |
| `filter[when]=upcoming\|ongoing\|past` | `upcoming() …` |
| `filter[from], filter[to]` | Overlapping the range, wall clock in ?tz= (default app.timezone). |
| `filter[date]=2026-11-14` | onLocalDate() — a real calendar date (2027-02-30 is a 422). |
| `filter[near]=48.14,17.11,25` | near() — radius optional, ≤ geo.max\_radius\_km. |
| `filter[category]=jazz` | inCategory() with sub-categories. |
| `filter[labels]=live,outdoor` | Every label. |
| `filter[organizer]=user:42` | organizedBy() — the row is never looked up; a missing one matches nothing. |
| `filter[venue]=7,8 · filter[mode]=online,mixed` | `atVenue() · withAttendanceMode()` |
| `filter[free]=true · filter[available]=true` | `free() · withSeatsAvailable()` |
| `filter[search]=…` | search() — escaped contains-search on title and description (commas kept). |
| `filter[status]=postponed,rescheduled` | `inPhase()` |
| `filter[mine]=organizing\|attending\|interested` | Your own events, every visibility (organizing includes drafts); guests get none. |
| `sort=starts_at (default), -starts_at, created_at, -created_at, popularity, distance` | distance needs filter\[near\] (400 otherwise); popularity ranks an event with show\_counts off as zero going — except under filter\[mine\]=organizing. |
| `per_page` | Default 15, ≤ events.routes.max\_per\_page. |

An invalid filter value is a 422 invalid\_query. A page loads venues and owners, organizers, tags, posters, ticket types, the viewer’s own RSVP, the category paths and which events the viewer manages in a fixed number of queries — the count does not grow with the page.

## Resources

A free event, as a guest sees it in the list:

```json
{
  "id": "0195f2c4-7a1e-7c3b-9d2e-5b8a1f4c6e21",
  "slug": "jazz-night",
  "title": "Jazz Night",
  "description": "A live quartet, then an open jam session.",
  "phase": "scheduled",
  "schema_status": "EventScheduled",
  "attendance_mode": "offline",
  "starts_at": "2026-11-14T20:00:00+01:00",
  "ends_at": "2026-11-14T23:00:00+01:00",
  "doors_open_at": null,
  "previous_starts_at": null,
  "all_day": false,
  "timezone": "Europe/Bratislava",
  "starts_on": "2026-11-14",
  "ends_on": "2026-11-14",
  "venue": {
    "id": 7,
    "name": "Blue Note",
    "slug": "blue-note",
    "address": {
      "street": "Hlavná 1", "city": "Bratislava", "region": null, "postal_code": "811 01",
      "country": "SK", "formatted": "Hlavná 1, 811 01 Bratislava, SK"
    },
    "coordinates": { "latitude": 48.1439, "longitude": 17.1097 },
    "timezone": "Europe/Bratislava",
    "capacity": null,
    "owner": null,
    "distance_km": null,
    "deleted": false
  },
  "online_url": null,
  "organizer": { "id": 1, "type": "User" },
  "category": { "id": 3, "slug": "jazz", "name": "Jazz", "color": null, "icon": null, "path": ["Music"] },
  "labels": [{ "id": 4, "slug": "live", "name": "Live", "color": null, "icon": null }],
  "poster": null,
  "tiers": [
    {
      "id": 1, "key": "free", "name": "Free entry", "description": null, "price": null, "free": true,
      "capacity": null, "seats_left": null, "on_sale": true, "sale_starts_at": null, "sale_ends_at": null,
      "min_party": 1, "max_party": null, "position": 1
    }
  ],
  "capacity": 120,
  "seats_left": 85,
  "counts": { "going": 35, "waitlisted": 0 },
  "viewer": { "rsvp": null },
  "links": {
    "self": "https://example.com/api/events/0195f2c4-7a1e-7c3b-9d2e-5b8a1f4c6e21",
    "ics": "https://example.com/api/events/0195f2c4-7a1e-7c3b-9d2e-5b8a1f4c6e21/calendar.ics",
    "url": "https://example.com/en/events/jazz-night"
  }
}
```

| Resource | Shape |
| --- | --- |
| `EventResource` | See the example above. status and visibility for managers only; online\_url for managers and going attendees; seats\_left and counts for managers, or everyone with show\_counts (counts.interested for managers); hosts and viewer.can on the detail view only. |
| `VenueResource` | id, name, slug, address {street, city, region, postal\_code, country, formatted}, coordinates {latitude, longitude} \| null, timezone, capacity, owner, distance\_km (3 decimals, when selected), deleted — never meta |
| `AdmissionTierResource` | id, key, name, description, price (MoneyResource or null), free, capacity, seats\_left, on\_sale, sale\_starts\_at, sale\_ends\_at, min\_party, max\_party, position, and hidden for managers. |
| `RsvpResource` | id, status, guests, party\_size, tier {id, key, name}, note, waitlist\_position, held\_until, amount, checked\_in\_at, checked\_in\_guests, responded\_at; the owner view adds payment\_url (while held), check\_in\_code and qr\_url; the managers’ view adds attendee, check\_in\_method and checked\_in\_by — never the code. |
| `InvitationResource` | id, status, invitee, invited\_by, message, expires\_at (null while postponed), responded\_at, sent\_at — never the token. |
| `CheckInResultResource` | first\_check\_in, guests, checked\_in\_at, method, attendee, rsvp (RsvpResource) |

## Reference resources

Every person or team a response mentions renders through its own resource — by default {id, type}, type being the model’s class basename (User), never a class name or a morph type, even without a morph map. A missing model renders null:

| Config key | Default | Renders |
| --- | --- | --- |
| `events.resources.organizer` | `EventOrganizerResource` | `organizer` |
| `events.resources.host` | `EventHostResource` | co-hosts |
| `events.resources.attendee` | `EventAttendeeResource` | attendees |
| `events.resources.invitee` | `EventInviteeResource` | invitees |
| `events.resources.actor` | `EventActorResource` | `invited_by, checked_in_by` |
| `events.resources.venue_owner` | `VenueOwnerResource` | a venue’s owner |

Point a key at your own JsonResource — it receives the model — to add a name or an avatar. Blank means the default; anything that is not an instantiable JsonResource subclass throws, naming the key. Lists eager-load the related models, so a resource that reads only their attributes adds no queries:

```php
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

final class PersonResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return ['id' => $this->resource->getKey(), 'name' => $this->resource->name, 'avatar' => $this->resource->avatar_url];
    }
}

// config/events.php
'resources' => [
    'organizer' => App\Http\Resources\PersonResource::class,
    'host' => EventHostResource::class,
    'attendee' => App\Http\Resources\PersonResource::class,
    'invitee' => EventInviteeResource::class,
    'actor' => EventActorResource::class,
    'venue_owner' => VenueOwnerResource::class,
],
```

## Errors

{message, code} with the package’s status — plus reason for check-in refusals, denials for a refused transition and errors for a validation 422. Messages are translated and never name a class, and your exception handler is never changed:

```json
{ "message": "This code is for another event.", "code": "check_in_refused", "reason": "wrong_event" }
```

| Status | code | When |
| --- | --- | --- |
| 401 | (your handler) | A write without a signed-in Eloquent user. |
| 403 | `forbidden · invitation_required` | The policy refused · a private event without an invitation. |
| 404 | `event_not_found, venue_not_found, tier_not_found, rsvp_not_found, invalid_code, invalid_invitation` | Missing or hidden. |
| 409 | `event_trashed, rsvp_closed, event_full, tier_sold_out, tier_not_on_sale, hold_expired, invalid_rsvp_transition, check_in_refused` | A state conflict (check\_in\_refused adds reason). |
| 422 | `validation_failed, invalid_event, invalid_schedule, invalid_venue, venue_in_use, invalid_tier, tier_key_taken, tier_in_use, tier_required, invalid_guest_count, invalid_organizer, unknown_alias, unknown_transition, transition_denied, invalid_query, tag_not_found, tag_limit_exceeded, invalid_tag, invalid_poster` | Bad input (validation\_failed adds errors, transition\_denied adds denials). |
| 400 | `invalid_query` | sort=distance without filter\[near\]. |
| 429 | transition\_denied (+ Retry-After) or the throttle | Rate limited. |

A tag of another scope answers tag\_not\_found, like a missing one. A poster media-library refuses, or an unknown or expired draft, is invalid\_poster.

## Rate limits

Defined only when the routes are on, per minute:

| Limiter | Default | Keyed by | Routes |
| --- | --- | --- | --- |
| `events-api` | `routes.rate_limits.api = 120` | user, else IP | Every route (in routes.middleware). |
| `events-rsvp` | `rate_limits.rsvp = 20` | user (or IP) and event | PUT / DELETE /rsvp, the invitation accept / decline. |
| `events-check-in` | `rate_limits.check_in = 300` | user | POST /check-ins — door scanners are bursty. |
| `events-feeds` | `rate_limits.feeds = 60` | `IP` | feed.ics, calendar.ics |

## Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

[More ways to support, including crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/events-for-laravel.md)
