Manage calendars, availability and appointments from external systems.
Base URL: https://muserelay.com/api/calendar/v1
Authentication: Authorization: Bearer mrcal_...
See the API introduction for authentication, rate limits and conventions.
Required scopes
| Scope | Endpoints that require it |
|---|---|
calendar:read | GET /me, GET /calendars, GET /calendars/{calendar}/services, GET /calendars/{calendar}/resources |
calendar:availability | GET /calendars/{calendar}/availability |
calendar:appointments:read | GET /calendars/{calendar}/appointments, GET /appointments/{appointment} |
calendar:appointments:create | POST /calendars/{calendar}/appointments |
calendar:appointments:cancel | POST /appointments/{appointment}/cancel |
calendar:appointments:update | PATCH /appointments/{appointment} |
calendar:admin | Special scope: satisfies any required scope |
If the token doesn't have the scope:
{ "error": "Insufficient scope" }
Me
GET /me
Returns basic information about the current token.
GET /api/calendar/v1/me
Authorization: Bearer mrcal_...
{
"token": {
"id": 10,
"name": "Retell AI clinic",
"scopes": ["calendar:read", "calendar:availability", "calendar:appointments:create"],
"calendar_ids": [12],
"expires_at": null
},
"workspace_id": 3,
"organization_id": 2
}
Calendars
GET /calendars
Lists the active calendars the token can access.
GET /api/calendar/v1/calendars
Authorization: Bearer mrcal_...
{
"data": [
{
"id": 12,
"name": "Main calendar",
"slug": "main-calendar",
"timezone": "Europe/Madrid",
"time_format": "24h",
"is_default": true,
"brand_color": "#2563eb",
"public_url": "https://muserelay.com/c/main-calendar"
}
]
}
GET /calendars/{calendar}/services
Lists the calendar's active appointment types.
GET /api/calendar/v1/calendars/12/services
Authorization: Bearer mrcal_...
{
"data": [
{
"id": 5,
"name": "Haircut",
"description": "Sample service",
"duration_minutes": 30,
"buffer_before_minutes": 0,
"buffer_after_minutes": 0,
"requires_approval": false,
"allows_group_booking": false,
"max_party_size": 1,
"price": "25.00",
"color": "#2563eb"
}
]
}
GET /calendars/{calendar}/resources
Lists the calendar's active resources (people, rooms, equipment or any bookable resource).
GET /api/calendar/v1/calendars/12/resources
Authorization: Bearer mrcal_...
{
"data": [
{
"id": 3,
"name": "Maria",
"type": "person",
"capacity": 1,
"color": "#2563eb"
}
]
}
Availability
GET /calendars/{calendar}/availability
Returns free slots for a service within a date range.
Parameters:
| Parameter | Type | Required | Rules |
|---|---|---|---|
service_id | integer | Yes | min:1 |
from | date | Yes | Start date/time |
to | date | Yes | Must be after from |
resource_id | integer | No | min:1 |
limit | integer | No | min:1, max:500, default 120 |
GET /api/calendar/v1/calendars/12/availability?service_id=5&from=2026-05-12&to=2026-05-16&limit=20
Authorization: Bearer mrcal_...
{
"data": [
{
"start_at": "2026-05-12T08:00:00+00:00",
"end_at": "2026-05-12T08:30:00+00:00",
"day": "2026-05-12",
"start_local": "10:00",
"end_local": "10:30",
"timezone": "Europe/Madrid",
"resource_id": 3,
"resource_name": "Maria",
"capacity_total": 1,
"capacity_available": 1
}
]
}
start_local and end_local are HH:mm strings in the calendar's time zone (not ISO 8601). In appointment responses they are full ISO 8601 with offset.If to is not after from:
{ "message": "to_must_be_after_from" }
Appointments
The appointment response uses the same structure in every endpoint:
{
"id": 4821,
"calendar_id": 12,
"service_id": 5,
"service_name": "Haircut",
"resource_id": 3,
"resource_name": "Maria",
"contact_id": 88,
"customer": {
"id": 88,
"name": "Jane Smith",
"email": "jane@example.com",
"phone": "+34600111222"
},
"start_at": "2026-05-12T10:30:00+02:00",
"end_at": "2026-05-12T11:00:00+02:00",
"start_local": "2026-05-12T10:30:00+02:00",
"end_local": "2026-05-12T11:00:00+02:00",
"timezone": "Europe/Madrid",
"status": "confirmed",
"appointment_kind": "booking",
"created_via": "api",
"party_size": 1,
"notes": "Booked from Retell AI",
"created_at": "2026-05-11T18:42:00+02:00"
}
start_local and end_local are the same dates as start_at/end_at converted to the calendar's time zone (ISO 8601 with local offset).POST /calendars/{calendar}/appointments
Creates an appointment. Use Idempotency-Key to avoid duplicates on retries.
| Header | Required | Description |
|---|---|---|
Authorization | Yes | Bearer mrcal_... |
Content-Type | Yes | application/json |
Idempotency-Key | No | Idempotency key cached for 24 hours |
Body:
| Field | Type | Required | Rules |
|---|---|---|---|
service_id | integer | Yes | min:1 |
start_at | date | Yes | Start date/time |
customer | object | Yes | Required object |
customer.name | string | No | max:255 |
customer.first_name | string | No | max:120 |
customer.last_name | string | No | max:120 |
customer.email | No | max:255 | |
customer.phone | string | No | max:64 |
resource_id | integer | No | min:1 |
any_resource | boolean | No | Selects the first free resource |
status | string | No | pending or confirmed |
party_size | integer | No | min:1, max:999 |
notes | string | No | max:4000 |
source | string | No | max:64 |
external_id | string | No | max:191 |
POST /api/calendar/v1/calendars/12/appointments
Authorization: Bearer mrcal_...
Content-Type: application/json
Idempotency-Key: retell-call-abc123-slot-20260512-1030
{
"service_id": 5,
"start_at": "2026-05-12T10:30:00+02:00",
"customer": {
"name": "Jane Smith",
"phone": "+34600111222",
"email": "jane@example.com"
},
"any_resource": true,
"notes": "Booked from Retell AI",
"source": "retell_ai"
}
Response 201 Created: full appointment object wrapped in { "data": { ... } }.
If the same Idempotency-Key is repeated within 24h: 200 with X-Idempotent-Replay: true.
If no resource is available: 422 { "message": "slot_unavailable" }
GET /calendars/{calendar}/appointments
Lists the calendar's appointments.
| Parameter | Type | Rules |
|---|---|---|
from | date | Optional |
to | date | Optional |
status | string | pending, confirmed, cancelled, completed, no_show |
limit | integer | min:1, max:200, default 100 |
GET /api/calendar/v1/calendars/12/appointments?from=2026-05-12&to=2026-05-19&status=confirmed&limit=50
Authorization: Bearer mrcal_...
Responds with { "data": [ ... ] } — an array of appointment objects.
GET /appointments/{appointment}
Returns an appointment by its ID.
GET /api/calendar/v1/appointments/4821
Authorization: Bearer mrcal_...
Responds with { "data": { ... } }.
POST /appointments/{appointment}/cancel
Cancels an appointment. If it is already cancelled, returns the appointment unchanged.
| Field | Type | Rules |
|---|---|---|
reason | string | max:1000, default cancelled_via_calendar_api |
notify_customer | boolean | Default false |
POST /api/calendar/v1/appointments/4821/cancel
Authorization: Bearer mrcal_...
Content-Type: application/json
{
"reason": "The customer requested cancellation by phone",
"notify_customer": true
}
Responds 200 with the appointment object with "status": "cancelled".
PATCH /appointments/{appointment}
Modifies or reschedules an appointment. All fields are optional.
| Field | Type | Rules |
|---|---|---|
service_id | integer | min:1 |
resource_id | integer | min:1 |
start_at | date | New start date/time |
status | string | pending, confirmed, cancelled, completed, no_show |
notes | string | max:4000 |
notify_customer | boolean | Default false |
Sending start_at triggers appointment.rescheduled. Other fields trigger appointment.updated.
PATCH /api/calendar/v1/appointments/4821
Authorization: Bearer mrcal_...
Content-Type: application/json
{
"start_at": "2026-05-14T16:00:00+02:00",
"notify_customer": true
}
Responds 200 with the updated appointment object.
Integration guides
Retell AI
Typical flow for booking during a call:
1. Agent detects booking intent
2. GET /calendars/{calendar}/availability with service_id
3. Caller picks a time
4. POST /calendars/{calendar}/appointments with Idempotency-Key
5. Appointment created with created_via = api
Minimum scopes: calendar:read, calendar:availability, calendar:appointments:create
Use an Idempotency-Key made of call ID + slot to avoid double bookings on retries.
n8n / Make
Method: GET
URL: https://muserelay.com/api/calendar/v1/calendars/{{$json["calendar_id"]}}/availability
Headers:
Authorization: Bearer mrcal_...
Query params:
service_id: {{$json["service_id"]}}
from: {{$today.format("YYYY-MM-DD")}}
to: {{$today.plus(7, "days").format("YYYY-MM-DD")}}
limit: 20
Webhooks
MuseRelay sends HTTP POST notifications when appointment events occur.
Available events
| Event | When it fires |
|---|---|
appointment.created | When an appointment is created |
appointment.updated | When fields are updated |
appointment.cancelled | When cancelled |
appointment.rescheduled | When moved to another time |
Configuration
Panel → Settings → API & Integrations → Webhooks.
| Field | Description |
|---|---|
url | Destination URL |
events | List of events; empty = all |
calendar_ids | List of calendars; empty = all |
is_active | Enables or disables the endpoint |
The generated secret uses the mrcal_whsec_ prefix.
Signature headers
| Header | Value |
|---|---|
X-MuseRelay-Event | Event name, e.g. appointment.created |
X-MuseRelay-Delivery | Delivery ID with the mrcal_evt_ prefix |
X-MuseRelay-Timestamp | Unix timestamp in seconds |
X-MuseRelay-Signature | sha256= + HMAC-SHA256 |
Verify the signature
The signature is HMAC-SHA256 over {X-MuseRelay-Timestamp}.{raw_json_body}.
PHP:
$secret = 'mrcal_whsec_...';
$timestamp = $_SERVER['HTTP_X_MUSERELAY_TIMESTAMP'] ?? '';
$rawBody = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
$received = $_SERVER['HTTP_X_MUSERELAY_SIGNATURE'] ?? '';
if (!hash_equals($expected, $received)) {
http_response_code(401);
exit('Invalid signature');
}
Node.js:
const crypto = require('crypto');
const secret = 'mrcal_whsec_...';
const timestamp = req.headers['x-muserelay-timestamp'] ?? '';
const signed = `${timestamp}.${req.rawBody}`;
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(signed).digest('hex');
const received = req.headers['x-muserelay-signature'] ?? '';
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))) {
res.status(401).send('Invalid signature');
}
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE.Sample payload — appointment.created
{
"id": "mrcal_evt_550e8400-e29b-41d4-a716-446655440000",
"event": "appointment.created",
"created_at": "2026-05-11T18:42:00+00:00",
"data": {
"id": 4821,
"appointment_id": 4821,
"calendar_id": 12,
"workspace_id": 3,
"organization_id": 2,
"service_id": 5,
"service_name": "Haircut",
"resource_id": 3,
"resource_name": "Maria",
"contact_id": 88,
"customer": {
"id": 88,
"name": "Jane Smith",
"email": "jane@example.com",
"phone": "+34600111222"
},
"start_at": "2026-05-12T10:30:00+02:00",
"end_at": "2026-05-12T11:00:00+02:00",
"timezone": "Europe/Madrid",
"status": "confirmed",
"appointment_kind": "booking",
"created_via": "api",
"party_size": 1,
"notes": "Booked from Retell AI"
}
}
Retry policy
| Item | Value |
|---|---|
| Maximum attempts | 3 |
| Backoff | fixed 30 seconds |
| Timeout HTTP | 10 seconds |
| Success | Any HTTP 2xx status |