# Book an appointment

`POST https://app.pozyskajpacjenta.pl/api/v1/booking/appointments` · operationId `createAppointment` · since 1.2.0 · tag Booking

Books a visit at a free slot from GET /booking/slots, in the same calendar as the clinic's site. The patient's lead is found by phone number (however it is written) or created; the booking moves the lead to the booked stage unless it is already at a visited, not interested or unreachable stage (the stage stays, the visit is stored). Reception gets a "Nowa wizyta" e-mail with an .ics file. Sending the same data again within 5 minutes returns the existing appointment instead of a second one. If the slot was taken in the meantime you get 409 with that day's free slots.

## Request

Authorization: `Bearer <the clinic's key>`.

### Body (application/json)

The appointment as JSON.

- `serviceId` (`string`, required): The booking service, an id from the catalog's `services`.
- `resourceId` (`string`, required): The specialist, the `resourceId` of the slot.
- `startAt` (`integer`, required): Start, epoch seconds (UTC): the `startAt` of a free slot.
- `patient` (`object`, required): The patient.
  - `name` (`string`, required): Full name.
  - `phone` (`string`, required): Phone number; the lead is matched by it, however it is written.
  - `email` (`string`): E-mail address, optional.
- `note` (`string`): Note for reception, e.g. the reason for the visit.
- `privacyNoticeVersion` (`string`, since 1.7.0): The `version` of the information clause your form showed (from GET /privacy). Stored with the submission and its time; without it the submission is stored without a clause version.
- `marketingConsent` (`boolean`, since 1.7.0): true when the patient ticked the optional marketing box (label `marketingText` from GET /privacy). Recorded for SMS (when there is a phone) and e-mail (when there is an address) together with the clause version. It counts only with a valid `privacyNoticeVersion`: without it no consent is recorded and an earlier opt-out stays. For a returning patient booking an appointment, the e-mail consent covers only the address stored with their lead (or the given address when the lead had none). Omit it, or send false, when the box was not ticked.

### cURL

```bash
curl -X POST "https://app.pozyskajpacjenta.pl/api/v1/booking/appointments" \
  -H "Authorization: Bearer $PP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "serviceId": "bsvc_konsultacja",
    "resourceId": "bres_piotr",
    "startAt": 1791181800,
    "patient": {
      "name": "Jan Nowak",
      "phone": "+48 600 100 200",
      "email": "jan.nowak@example.com"
    },
    "note": "Pierwsza wizyta, ząb boli przy zimnym."
  }'
```

### TypeScript

```ts
import { PozyskajPacjentaClient, isSlotTaken } from "@pozyskajpacjenta/sdk";

const client = new PozyskajPacjentaClient({
  apiKey: process.env.PP_API_KEY ?? "",
  baseUrl: "https://app.pozyskajpacjenta.pl",
});

try {
  const { appointment } = await client.createAppointment({
    serviceId: "bsvc_konsultacja",
    resourceId: "bres_piotr",
    startAt: 1791181800,
    patient: {
      name: "Jan Nowak",
      phone: "+48 600 100 200",
      email: "jan.nowak@example.com",
    },
    note: "Pierwsza wizyta, ząb boli przy zimnym.",
  });
  console.log(appointment.id);
} catch (error) {
  // 409: the slot was taken in the meantime; offer one of that day's free slots
  if (isSlotTaken(error)) console.log(error.slots);
  else throw error;
}
```

### PHP

```php
<?php
$body = array(
    'serviceId'  => 'bsvc_konsultacja',
    'resourceId' => 'bres_piotr',
    'startAt'    => 1791181800,
    'patient'    => array(
        'name'  => 'Jan Nowak',
        'phone' => '+48 600 100 200',
        'email' => 'jan.nowak@example.com',
    ),
    'note'       => 'Pierwsza wizyta, ząb boli przy zimnym.',
);

$response = wp_remote_post(
    'https://app.pozyskajpacjenta.pl/api/v1/booking/appointments',
    array(
        'headers' => array(
            'Authorization' => 'Bearer ' . getenv( 'PP_API_KEY' ),
            'Content-Type'  => 'application/json',
        ),
        'body'    => wp_json_encode( $body ),
        'timeout' => 10,
    )
);
if ( is_wp_error( $response ) ) {
    error_log( $response->get_error_message() );
    return;
}
$status = wp_remote_retrieve_response_code( $response );
$data   = json_decode( wp_remote_retrieve_body( $response ), true );
```

## Response

### 201: The appointment was booked.

- `appointment` (`object`, required): The appointment.
  - `id` (`string`, required): Appointment id.
  - `startAt` (`integer`, required): Start, epoch seconds (UTC).
  - `endAt` (`integer`, required): End, epoch seconds (UTC).
  - `leadId` (`string`, required): The patient's lead in the clinic's CRM.

Example (Appointment booked (a resend within 5 minutes returns the same)):

```json
{
  "appointment": {
    "id": "Onqp5yPLKb3IFff2WKfnV",
    "startAt": 1791181800,
    "endAt": 1791183600,
    "leadId": "lead_1"
  }
}
```

## Errors

| Status | code | When |
| --- | --- | --- |
| 400 |  | The body is not JSON |
| 400 |  | serviceId, resourceId or startAt missing |
| 400 |  | patient.name or patient.phone missing |
| 400 | `nfz_phone_only` | An NFZ service (phone booking only) |
| 401 |  | No Authorization header |
| 401 |  | Unknown or rotated key |
| 409 | `slot_taken` | The slot was taken in the meantime (slots trimmed to three) |
| 429 |  | Over 120 requests in this minute |

400 (The body is not JSON):

```json
{
  "error": "invalid JSON"
}
```

400 (serviceId, resourceId or startAt missing):

```json
{
  "error": "wymagane: serviceId, resourceId, startAt (epoch s, UTC)"
}
```

400 (patient.name or patient.phone missing):

```json
{
  "error": "wymagane: patient.name, patient.phone"
}
```

400 (An NFZ service (phone booking only)):

```json
{
  "error": "usługa NFZ — rejestracja telefoniczna",
  "code": "nfz_phone_only"
}
```

401 (No Authorization header):

```json
{
  "error": "missing Authorization: Bearer <klucz z panelu Ustawienia>"
}
```

401 (Unknown or rotated key):

```json
{
  "error": "invalid key"
}
```

409 (The slot was taken in the meantime (slots trimmed to three)):

```json
{
  "error": "slot_taken",
  "slots": [
    {
      "resourceId": "bres_piotr",
      "startAt": 1791183600,
      "endAt": 1791185400,
      "localDate": "2026-10-05",
      "localStartMin": 540
    },
    {
      "resourceId": "bres_piotr",
      "startAt": 1791184500,
      "endAt": 1791186300,
      "localDate": "2026-10-05",
      "localStartMin": 555
    },
    {
      "resourceId": "bres_piotr",
      "startAt": 1791185400,
      "endAt": 1791187200,
      "localDate": "2026-10-05",
      "localStartMin": 570
    }
  ]
}
```

429 (Over 120 requests in this minute):

```json
{
  "error": "przekroczono limit 120 zapytań/min"
}
```

## Rate limit

- 120 requests per minute per key, shared by every endpoint.

## SDK method

`client.createAppointment(input: AppointmentInput): Promise<AppointmentCreated>`

Throws SlotTakenError (409, with fresh `slots`); ApiError 400.
