---
title: Duplicate a service
description: "Clones a configured service in one transaction: the service row (title suffixed \" (copy)\", fresh unique slug, hidden: true so the clone never reaches a..."
api_method: POST
api_path: "/v1/services/{serviceId}/duplicate"
canonical_url: https://wiblo.app/docs/developers/api/services/duplicate-service
last_updated: 2026-07-28T17:31:44+02:00
md_url: https://wiblo.app/docs/developers/api/services/duplicate-service.md
---

# Duplicate a service

`POST /v1/services/{serviceId}/duplicate`

Clones a configured service in one transaction: the service row (title suffixed " (copy)", fresh unique slug, `hidden: true` so the clone never reaches a public surface, `position` after the source) plus host assignments, host restrictions, resource requirements, tag assignments, and a deep copy of a service-owned schedule with its availability rules. Bookings and `retired_at` never travel with the copy — duplicating a retired service yields a live hidden clone. Owner/admin only. Per-user rate-limited at 60/60s.

## Path parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `serviceId` | `string` | Yes |  |

## 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

**curl**

```bash
curl https://api.wiblo.app/v1/services/3f8a2b9c-51d4-4e0b-9c6a-7d2e8f1a4b5c/duplicate \
  -X POST \
  -H "Authorization: Bearer $WIBLO_TOKEN" \
  -H "X-Wiblo-Workspace: $WIBLO_WORKSPACE_ID"
```

**TypeScript**

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

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

const { data, error } = await duplicateService({
  client: sdk,
  path: { serviceId: "3f8a2b9c-51d4-4e0b-9c6a-7d2e8f1a4b5c" },
  headers: { "X-Wiblo-Workspace": "..." },
})
```

## Example response

```json
{
  "id": "7a4d9e2b-6c1f-4b3a-9e8d-5b0c2f7a4d1e",
  "workspace_id": "9c1b7e24-6a3f-4d58-b2e9-0f4a8c6d1e37",
  "title": "Initial consultation (copy)",
  "slug": "initial-consultation-copy",
  "description": "A 30-minute intro call to scope your project and answer questions.",
  "category": "Consultations",
  "hidden": true,
  "position": 2,
  "length": 30,
  "time_zone": null,
  "slot_interval": 30,
  "offset_start": 0,
  "price": 7500,
  "currency": "usd",
  "scheduling_type": "round_robin",
  "assign_all_team_members": false,
  "is_rr_weights_enabled": false,
  "reschedule_with_same_rr_host": true,
  "minimum_booking_notice": 120,
  "before_event_buffer": 10,
  "after_event_buffer": 10,
  "booking_limits": {
    "day": 4,
    "week": 20
  },
  "duration_limits": null,
  "max_active_bookings_per_booker": 3,
  "period_type": "rolling",
  "period_days": 60,
  "period_start_date": null,
  "period_end_date": null,
  "locations": [
    {
      "type": "video",
      "link": "https://meet.example.com/acme"
    }
  ],
  "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": "#0ea5e9",
    "dark": "#38bdf8"
  },
  "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": [
    {
      "id": "d4c8b1a2-3e5f-4a6b-8c9d-0e1f2a3b4c5d",
      "name": "Intro",
      "color": "#38bdf8"
    }
  ]
}
```

## Responses

**`201`** — The newly created clone. 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. |

**`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`** — No service visible to the actor with this id in this workspace (`SERVICE_NOT_FOUND`). Returns `ApiErrorEnvelope`.

**`409`** — Workspace is archived (`WORKSPACE_ARCHIVED`), or a concurrent write claimed the minted slug (`SLUG_TAKEN`) — a retry self-heals. Returns `ApiErrorEnvelope`.

**`422`** — Path param failed UUID validation (`INVALID_PARAMS`). 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. |
