---
title: Create a service
description: "Creates a bookable service in the header's workspace."
api_method: POST
api_path: "/v1/services"
canonical_url: https://wiblo.app/docs/developers/api/services/create-service
last_updated: 2026-07-28T17:31:44+02:00
md_url: https://wiblo.app/docs/developers/api/services/create-service.md
---

# Create a service

`POST /v1/services`

Creates a bookable service in the header's workspace. Owner/admin only. `slug` is derived from `title` when omitted; a duplicate slug within the workspace is 409 `SLUG_TAKEN`. Seated/recurring configuration is stored but behaviorally inert until WIBLO-131/132. Per-user rate-limited at 60/60s.

## Headers

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `X-Wiblo-Workspace` | `string` | Yes | Workspace id the call is scoped to. The actor must hold an active membership in it; a foreign or unknown id collapses onto 404 WORKSPACE_NOT_FOUND. |

## Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `title` | `string` | Yes | Display name of the service. |
| `slug` | `string` | No | URL-safe identifier, unique within the workspace (lowercase letters, numbers, inner hyphens). Derived from `title` when omitted. |
| `description` | `string` | No | Long-form description shown on the booking page. |
| `category` | `string \| null` | No | Free-form grouping label for the admin catalogue. |
| `length` | `integer` | Yes | Appointment length in minutes (5–720). |
| `slot_interval` | `integer \| null` | No | Minutes between offered start times (5–60); `null` falls back to the service length. |
| `offset_start` | `integer` | No | Minutes past each interval boundary that slots start at (0–59). Defaults to `0`. |
| `price` | `integer` | No | Price of the service; `0` means free. Defaults to `0`. |
| `currency` | `string` | No | Lowercase 3-letter ISO 4217 currency code. Defaults to `"usd"`. |
| `scheduling_type` | `"round_robin" \| "collective" \| null` | No | Team scheduling mode — `round_robin` rotates one host per booking, `collective` books every host together. `null` means single-host 1-on-1. |
| `minimum_booking_notice` | `integer` | No | Minimum lead time in minutes between booking and slot start. Defaults to `120`. |
| `before_event_buffer` | `integer` | No | Minutes blocked off before each booking (0–180). Defaults to `0`. |
| `after_event_buffer` | `integer` | No | Minutes blocked off after each booking (0–180). Defaults to `0`. |
| `booking_limits` | `object \| null` | No | Caps on booking counts per period; `null` means uncapped. |
| `booking_limits.day` | `integer` | No | Maximum bookings per calendar day. |
| `booking_limits.week` | `integer` | No | Maximum bookings per calendar week. |
| `booking_limits.month` | `integer` | No | Maximum bookings per calendar month. |
| `duration_limits` | `object \| null` | No | Caps on total booked minutes per period; `null` means uncapped. |
| `duration_limits.day` | `integer` | No | Maximum booked minutes per calendar day. |
| `duration_limits.week` | `integer` | No | Maximum booked minutes per calendar week. |
| `duration_limits.month` | `integer` | No | Maximum booked minutes per calendar month. |
| `max_active_bookings_per_booker` | `integer \| null` | No | Cap on live (pending/accepted) bookings one booker can hold for this service; `null` means uncapped. |
| `period_type` | `"unlimited" \| "rolling" \| "range"` | No | Which booking window applies — `unlimited` (any future slot), `rolling` (the next `period_days` days), or `range` (between `period_start_date` and `period_end_date`). Defaults to `"unlimited"`. |
| `period_days` | `integer \| null` | No | Size of the rolling window in days, when `period_type` is `rolling`. |
| `period_start_date` | `string \| null` | No | Start of the bookable range, when `period_type` is `range`. Accepts a datetime or a date-only `YYYY-MM-DD` string, which normalises to the UTC start of that day. |
| `period_end_date` | `string \| null` | No | End of the bookable range, when `period_type` is `range`. Accepts a datetime or a date-only `YYYY-MM-DD` string, which normalises to the UTC end of that day. |
| `locations` | `array<ServiceLocation>` | No | Where the appointment can happen; bookers pick one at booking time. |
| `recurring_event` | `object \| null` | No | Recurrence configuration for repeat bookings; `null` for one-off services. |
| `recurring_event.freq` | `integer` | Yes | RRule frequency constant — 0 yearly, 1 monthly, 2 weekly, 3 daily. |
| `recurring_event.interval` | `integer` | Yes | Repeat every N periods of `freq` (1–12). |
| `recurring_event.count` | `integer` | Yes | Total occurrences in the series (2–24). |
| `requires_confirmation` | `boolean` | No | New bookings start pending until a host confirms them. Defaults to `false`. |
| `requires_booker_email_verification` | `boolean` | No | Bookers must verify their email before the booking is accepted. Defaults to `false`. |
| `disable_cancelling` | `boolean` | No | Removes the booker's cancel action on this service's bookings. Defaults to `false`. |
| `disable_rescheduling` | `boolean` | No | Removes the booker's reschedule action on this service's bookings. Defaults to `false`. |
| `hide_organizer_email` | `boolean` | No | Hides the organizer's email on booking surfaces and notifications. Defaults to `false`. |
| `lock_time_zone` | `boolean` | No | Pins the booking page to `time_zone` instead of the visitor's local zone. Defaults to `false`. |
| `time_zone` | `string \| null` | No | IANA time zone the service is anchored to, e.g. `Europe/London`. |
| `hidden` | `boolean` | No | Hidden services never appear on public booking surfaces. Defaults to `false`. |
| `color` | `object \| null` | No | Accent colour for the service in calendars and admin chips. |
| `color.light` | `string` | Yes | Hex accent colour used on light surfaces. |
| `color.dark` | `string` | Yes | Hex accent colour used on dark surfaces. |
| `assign_all_team_members` | `boolean` | No | Automatically assign every current and future team member as a host. Defaults to `false`. |
| `reschedule_with_same_rr_host` | `boolean` | No | Reschedules keep the originally assigned round-robin host instead of re-rotating. Defaults to `false`. |
| `is_rr_weights_enabled` | `boolean` | No | Bias round-robin rotation using each host's `weight`. Defaults to `false`. |
| `seats_per_time_slot` | `integer \| null` | No | Attendee seats a single slot can hold; `null` disables seated mode. |
| `seats_show_attendees` | `boolean` | No | Show existing attendees to prospective bookers. Defaults to `false`. |
| `seats_show_availability_count` | `boolean` | No | Show the remaining-seat count on the booking page. Defaults to `true`. |
| `metadata` | `object \| null` | No | Extensible metadata bag; only `requiresConfirmationThreshold` has a defined shape today. |
| `metadata.requiresConfirmationThreshold` | `object` | No | Only require confirmation when the booking is made less than `time` `unit`s before the slot starts. |

### The ServiceLocation object

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `type` | `"in_person" \| "phone" \| "video" \| "link"` | Yes | Kind of location the appointment happens at. |
| `address` | `string` | No | Street address, for `in_person` locations. |
| `link` | `string` | No | Join or meeting URL, for `video` and `link` locations. |
| `phone` | `string` | No | Phone number the host calls, for `phone` locations. |

## Request

**curl**

```bash
curl https://api.wiblo.app/v1/services \
  -X POST \
  -H "Authorization: Bearer $WIBLO_TOKEN" \
  -H "X-Wiblo-Workspace: $WIBLO_WORKSPACE_ID" \
  -H "Content-Type: application/json" \
  -d '{
  "title": "Follow-up session",
  "description": "A 45-minute working session for existing clients.",
  "category": "Consultations",
  "length": 45,
  "price": 12000,
  "currency": "usd",
  "scheduling_type": "round_robin",
  "minimum_booking_notice": 240,
  "before_event_buffer": 10,
  "after_event_buffer": 10,
  "booking_limits": {
    "day": 3,
    "week": 12
  },
  "period_type": "rolling",
  "period_days": 30,
  "locations": [
    {
      "type": "video",
      "link": "https://meet.example.com/acme"
    },
    {
      "type": "phone",
      "phone": "+44 20 7946 0958"
    }
  ],
  "color": {
    "light": "#0f766e",
    "dark": "#2dd4bf"
  }
}'
```

**TypeScript**

```ts
import { createSdk, createService } from "@workspace/sdk"

const sdk = createSdk({ baseUrl: "https://api.wiblo.app" })

const { data, error } = await createService({
  client: sdk,
  headers: { "X-Wiblo-Workspace": "..." },
  body: {
    "title": "Follow-up session",
    "description": "A 45-minute working session for existing clients.",
    "category": "Consultations",
    "length": 45,
    "price": 12000,
    "currency": "usd",
    "scheduling_type": "round_robin",
    "minimum_booking_notice": 240,
    "before_event_buffer": 10,
    "after_event_buffer": 10,
    "booking_limits": {
      "day": 3,
      "week": 12
    },
    "period_type": "rolling",
    "period_days": 30,
    "locations": [
      {
        "type": "video",
        "link": "https://meet.example.com/acme"
      },
      {
        "type": "phone",
        "phone": "+44 20 7946 0958"
      }
    ],
    "color": {
      "light": "#0f766e",
      "dark": "#2dd4bf"
    }
  },
})
```

## Example response

```json
{
  "id": "6e2d8f1a-4b7c-4d9e-a3f5-8c0b2e6d4a1f",
  "workspace_id": "9c1b7e24-6a3f-4d58-b2e9-0f4a8c6d1e37",
  "title": "Follow-up session",
  "slug": "follow-up-session",
  "description": "A 45-minute working session for existing clients.",
  "category": "Consultations",
  "hidden": false,
  "position": 3,
  "length": 45,
  "time_zone": null,
  "slot_interval": null,
  "offset_start": 0,
  "price": 12000,
  "currency": "usd",
  "scheduling_type": "round_robin",
  "assign_all_team_members": false,
  "is_rr_weights_enabled": false,
  "reschedule_with_same_rr_host": false,
  "minimum_booking_notice": 240,
  "before_event_buffer": 10,
  "after_event_buffer": 10,
  "booking_limits": {
    "day": 3,
    "week": 12
  },
  "duration_limits": null,
  "max_active_bookings_per_booker": null,
  "period_type": "rolling",
  "period_days": 30,
  "period_start_date": null,
  "period_end_date": null,
  "locations": [
    {
      "type": "video",
      "link": "https://meet.example.com/acme"
    },
    {
      "type": "phone",
      "phone": "+44 20 7946 0958"
    }
  ],
  "recurring_event": null,
  "requires_confirmation": false,
  "requires_booker_email_verification": false,
  "disable_guests": false,
  "hide_calendar_notes": false,
  "hide_calendar_event_details": false,
  "hide_organizer_email": false,
  "lock_time_zone": false,
  "disable_cancelling": false,
  "disable_rescheduling": false,
  "seats_per_time_slot": null,
  "seats_show_attendees": false,
  "seats_show_availability_count": true,
  "color": {
    "light": "#0f766e",
    "dark": "#2dd4bf"
  },
  "booking_fields": null,
  "metadata": null,
  "retired_at": null,
  "created_at": "2026-07-28T09:12:00.000Z",
  "updated_at": "2026-07-28T09:12:00.000Z",
  "tags": []
}
```

## Responses

**`201`** — Service created. Returns `ServiceResponse`.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | Yes | Unique id of the service. |
| `workspace_id` | `string` | Yes | Workspace the service belongs to. |
| `title` | `string` | Yes | Display name of the service. |
| `slug` | `string` | Yes | URL-safe identifier, unique within the workspace. |
| `description` | `string \| null` | Yes | Long-form description shown on the booking page. |
| `category` | `string \| null` | Yes | Free-form grouping label for the admin catalogue. |
| `hidden` | `boolean` | Yes | Hidden services never appear on public booking surfaces. |
| `position` | `integer` | Yes | Manual sort position; lists order by it, then creation time. |
| `length` | `integer` | Yes | Appointment length in minutes. |
| `time_zone` | `string \| null` | Yes | IANA time zone the service is anchored to; `null` follows the visitor. |
| `slot_interval` | `integer \| null` | Yes | Minutes between offered start times; `null` falls back to the service length. |
| `offset_start` | `integer` | Yes | Minutes past each interval boundary that slots start at. |
| `price` | `integer` | Yes | Price of the service; `0` means free. |
| `currency` | `string` | Yes | Lowercase 3-letter ISO 4217 currency code. |
| `scheduling_type` | `"round_robin" \| "collective" \| null` | Yes | Team scheduling mode — `round_robin` rotates one host per booking, `collective` books every host together. `null` means single-host 1-on-1. |
| `assign_all_team_members` | `boolean` | Yes | Every current and future team member is auto-assigned as a host. |
| `is_rr_weights_enabled` | `boolean` | Yes | Round-robin rotation is biased by each host's `weight`. |
| `reschedule_with_same_rr_host` | `boolean` | Yes | Reschedules keep the originally assigned round-robin host. |
| `minimum_booking_notice` | `integer` | Yes | Minimum lead time in minutes between booking and slot start. |
| `before_event_buffer` | `integer` | Yes | Minutes blocked off before each booking. |
| `after_event_buffer` | `integer` | Yes | Minutes blocked off after each booking. |
| `booking_limits` | `object` | No | Booking count caps (`BookingLimits` shape); `null` means uncapped. |
| `duration_limits` | `object` | No | Booked-minute caps (`DurationLimits` shape); `null` means uncapped. |
| `max_active_bookings_per_booker` | `integer \| null` | Yes | Cap on live bookings one booker can hold; `null` means uncapped. |
| `period_type` | `"unlimited" \| "rolling" \| "range"` | Yes | Which booking window applies — `unlimited` (any future slot), `rolling` (the next `period_days` days), or `range` (between `period_start_date` and `period_end_date`). |
| `period_days` | `integer \| null` | Yes | Size of the rolling window in days, when `period_type` is `rolling`. |
| `period_start_date` | `string \| null` | Yes | UTC start of the bookable range, when `period_type` is `range`. |
| `period_end_date` | `string \| null` | Yes | UTC end of the bookable range, when `period_type` is `range`. |
| `locations` | `object` | No | Locations offered at booking time (array of `ServiceLocation`). |
| `recurring_event` | `object` | No | Recurrence configuration (`RecurringEvent` shape); `null` for one-off services. |
| `requires_confirmation` | `boolean` | Yes | New bookings start pending until a host confirms them. |
| `requires_booker_email_verification` | `boolean` | Yes | Bookers must verify their email before the booking is accepted. |
| `disable_guests` | `boolean` | Yes | Bookers cannot add guest attendees. |
| `hide_calendar_notes` | `boolean` | Yes | Booking notes are hidden on synced calendar events. |
| `hide_calendar_event_details` | `boolean` | Yes | Event details are hidden on synced calendar events. |
| `hide_organizer_email` | `boolean` | Yes | The organizer's email is hidden on booking surfaces and notifications. |
| `lock_time_zone` | `boolean` | Yes | The booking page is pinned to `time_zone` instead of the visitor's local zone. |
| `disable_cancelling` | `boolean` | Yes | Bookers cannot cancel bookings of this service. |
| `disable_rescheduling` | `boolean` | Yes | Bookers cannot reschedule bookings of this service. |
| `seats_per_time_slot` | `integer \| null` | Yes | Attendee seats a single slot can hold; `null` means seated mode is off. |
| `seats_show_attendees` | `boolean` | Yes | Existing attendees are shown to prospective bookers. |
| `seats_show_availability_count` | `boolean` | Yes | The remaining-seat count is shown on the booking page. |
| `color` | `object` | No | Accent colour (`ServiceColor` shape); `null` uses the workspace default. |
| `booking_fields` | `object` | No | Custom booking-form field definitions; `null` when the default form is used. |
| `metadata` | `object` | No | Extensible metadata bag; `null` when unset. |
| `retired_at` | `string \| null` | Yes | When the service was retired; `null` for live services. |
| `created_at` | `string` | Yes | Creation timestamp (RFC 3339). |
| `updated_at` | `string` | Yes | Last-update timestamp (RFC 3339). |
| `tags` | `array<TagSummary>` | Yes | Workspace tags pinned to this service; `[]` when untagged. |

**`400`** — Body failed Zod validation, or the `X-Wiblo-Workspace` header was missing (`VALIDATION_FAILED`). Returns `ApiErrorEnvelope`.

**`401`** — No valid session cookie or `wbl_*` bearer was present. Returns `ApiErrorEnvelope`.

**`403`** — The actor is a plain member; owner or admin is required (`ADMIN_REQUIRED`). Returns `ApiErrorEnvelope`.

**`404`** — The actor has no active membership in the header's workspace (`WORKSPACE_NOT_FOUND`). Returns `ApiErrorEnvelope`.

**`409`** — Another service in the workspace already uses this slug (`SLUG_TAKEN`). Returns `ApiErrorEnvelope`.

**`429`** — Rate limit exceeded. The body's `error.code` is `RATE_LIMITED` and `error.details.retry_after` is the same number of seconds as the `Retry-After` header. Returns `ApiErrorEnvelope`.

### The TagSummary object

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | Yes | Unique id of the tag. |
| `name` | `string` | Yes | Display name, unique per workspace (case-insensitive). |
| `color` | `string \| null` | Yes | `#rrggbb` chip colour; `null` uses the default chip style. |
