WordPress Cliniko Stripe Plugin
A plugin to integrate a stripe custom checkout form with elementor and cliniko CMS
by Paulo Monteiro · github.com/pribm/wordpress-cliniko-stripe-plugin · website
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/pribm/wordpress-cliniko-stripe-plugin/archive/refs/heads/main.zipWordPress Cliniko Payment Integration
Production-ready WordPress plugin that connects Cliniko bookings and patient forms with payment flows in Stripe and Tyro Health, with Elementor widgets for custom booking experiences.
Version
- Current plugin version:
1.6.15
Overview
This plugin supports two booking approaches:
Cliniko Embed: Cliniko handles the booking UI in an iframe.Custom Form: your Elementor form collects answers, schedules appointments, and routes payment through the selected gateway.
For custom form mode, appointment scheduling can use:
Next Available TimeCalendar Selectionwith practitioner-aware availability
What Is New in 1.6.15
- Custom-form widgets can now send signed server-side webhooks for booking and payment milestones.
- Booking-attempt jobs now nudge Action Scheduler from public requests so async deliveries and booking work start promptly.
- Cliniko patient-form create and attach requests no longer send
email_to_patient_on_completionunless explicitly set.
Core Features
- Shard-aware Cliniko API integration.
- Elementor widgets for appointment cards and booking forms.
- Multi-step booking flow with custom step sequencing.
- Async scheduling pipeline through Action Scheduler (WP-Cron fallback).
- Validation pipeline for patient form payloads.
- Gateway handling for Stripe and Tyro Health.
- Signed server-side webhooks for custom-form booking events.
Requirements
- WordPress
>= 5.9 - PHP
>= 7.4(tested up to 8.2) - Elementor
>= 3.10 - PHP OpenSSL extension enabled
- Cliniko API key
- Stripe keys (publishable and secret) for Stripe mode
Installation
- Install the plugin in
/wp-content/plugins/or upload the ZIP from WordPress admin. - Activate the plugin.
- Open
Settings -> Cliniko Stripe Integration. - Configure credentials:
- Cliniko API Key
- Cliniko App Name
- Cliniko Shard (for example
au4) - Stripe Publishable Key
- Stripe Secret Key
- Click
Connect to Clinikoand select business, practitioner, and appointment type.
Cliniko app and shard can be derived from your Cliniko URL:
- Example:
https://my-clinic.au4.cliniko.com/... - App Name:
my-clinic - Shard:
au4
Elementor Widgets
Cliniko: Appointment Type Card
Displays appointment type details with configurable icon, price presentation, and CTA.
Main controls:
- Appointment type
- Icon and colors
- Price position
- Button text/icon/link
- Optional custom CSS class
Cliniko: Stripe Booking Form
Main booking wizard widget.
Main capabilities:
- Multi-step form flow
- Cliniko embed mode support
- Custom form mode support
- Optional payment step depending on gateway mode
Custom Form Flow
For custom form mode, the flow generally includes:
- Booking questions and patient details
- Appointment scheduling (
Next AvailableorCalendar Selection) - Payment handoff (if gateway enabled)
- Async scheduling and form processing in workers
Calendar mode behavior:
- Calendar step displays date grid and available slots.
- Practitioner selection is tied to calendar scheduling context.
- Date buttons with no availability are disabled.
- Slot selection updates
patient[appointment_start].
Gateway behavior:
- Final wizard action should continue to payment flow (not direct browser submit).
- Wizard UI can be hidden while payment UI is active.
Custom Form Webhooks
Custom-form widgets can send server-side webhook events to an external system without exposing webhook URLs, signing secrets, booking attempt tokens, payment tokens, or form payload templates to the browser.
Configure webhooks in the Elementor Cliniko: Stripe Booking Form widget:
- Enable
Webhooks. - Enter the destination
Webhook URL. - Select one or more events:
booking.preflightedpayment.verifiedbooking.completedbooking.failed
- Optionally set a signing secret. If left blank, the plugin generates and stores one server-side.
- Optionally enable basic patient fields. When enabled, only first name, last name, email, and phone are included.
Delivery behavior:
- Webhook settings are synced when the Elementor page is saved.
- Events are queued through Action Scheduler using the
cliniko_form_webhook_sendaction. - Failed deliveries with HTTP
4xx/5xxresponses or WordPress HTTP errors are retried up to three times after roughly 1 minute, 5 minutes, and 15 minutes. - The plugin sends
Content-Type: application/jsonand these headers:X-Cliniko-Webhook-EventX-Cliniko-Webhook-TimestampX-Cliniko-Webhook-Signature
Signature verification:
sha256 = HMAC_SHA256(timestamp + "." + raw_json_body, signing_secret)
Compare the calculated signature with the X-Cliniko-Webhook-Signature header after removing the sha256= prefix.
Payload privacy:
- Payment card last four digits and card brand are removed before delivery.
- Patient form answers, Medicare details, health identifiers, access tokens, attempt tokens, and Stripe tokens are never included in webhook payloads.
booking_attempt.idis included as an operational identifier, but the private booking attempt token is not.
Headless Mode (Custom Form)
Headless mode renders no form UI. The Cliniko template is exposed so you can build your own UI while keeping the payment step intact.
Where the template is exposed:
formHandlerData.sections(global JS object).cliniko-form-headless .cliniko-form-template-json(JSON script tag)
Submission-ready skeleton:
formHandlerData.submission_template.cliniko-form-headless .cliniko-form-submission-template-json(JSON script tag)
Headless calendar (build your own UI):
- Helper:
window.ClinikoHeadlessCalendar(available only in headless mode). - Defaults: if you omit
appointmentTypeId, it falls back toformHandlerData.module_id. - Date format: use
YYYY-MM-DDfordateKey,from, andto. - Dynamic updates:
updateFormtemplate(templateId)loads a new patient form template from backend, updatesformHandlerData.sections, and returns the fresh template object.updateAppointmentType(appointmentTypeId, updatePaymentStep = true)loads appointment-type details, updatesformHandlerData.module_id, refreshes payment summary values, and returns the fresh appointment object.
How to submit:
- Build a payload with
patientandcontentfrom your UI. - Expose it as
window.clinikoHeadlessPayloadorwindow.clinikoGetHeadlessPayload(). - Show the payment UI when ready.
Payment UI notes:
- Stripe: call
showStripePaymentForm()or set#payment_formtodisplay:flexand let the payment button handle submission. - Tyro Health: show
#payment_form. Ensure your headless patient fields map to the IDs/names read bytyrohealth.js(for example#patient-first-name,#patient-last-name,#patient-email), or adjusttyrohealth.jsto your field IDs.
Headless calendar flow (recommended):
- Load practitioners (optional).
- Load the current month grid via
fetchCalendar()and render it (you can usegrid_htmlor your own UI). - On date click, load times via
fetchAllTimesForDate(). - Group the times with
groupTimesByPeriod()for morning/afternoon/evening. - When the user selects a time, call
updateHeadlessPatient({ appointment_start, practitioner_id }). - If you use
clinikoGetHeadlessPayload(), write these fields into the returned payload yourself (the helper can’t mutate a computed payload).
Headless calendar example (minimal):
const cal = window.ClinikoHeadlessCalendar;
const practitioners = await cal.fetchPractitioners();
const practitionerId = practitioners?.[0]?.id || "";
const monthKey = cal.getMonthKeyFromDate(new Date());
const calendar = await cal.fetchCalendar({ practitionerId, monthKey });
// calendar.grid_html, calendar.month_label, calendar.month_key
const dateKey = "2026-02-10";
const times = await cal.fetchAllTimesForDate({ dateKey, practitionerId });
const buckets = cal.groupTimesByPeriod(times);
// render buckets.morning / buckets.afternoon / buckets.evening
cal.updateHeadlessPatient({ appointment_start: times[0], practitioner_id: practitionerId });
Minimal payload shape:
{
"moduleId": "appointment_type_id",
"patient_form_template_id": "form_template_id",
"patient": {
"first_name": "Jane",
"last_name": "Doe",
"email": "jane@example.com",
"phone": "0400 000 000",
"medicare": "1234 56789",
"medicare_reference_number": "1"
},
"content": {
"sections": [
{
"name": "Section Name",
"questions": [
{
"name": "Question Label",
"type": "text",
"required": true,
"answer": "Free text answer"
},
{
"name": "Options Question",
"type": "radiobuttons",
"required": true,
"answers": [
{ "value": "Yes", "selected": true },
{ "value": "No" }
]
}
]
}
]
}
}
Headless Payload Details
The widget exposes a submission template with these shapes. You can use it directly or clone it and fill the answers.
Patient object fields (all string values):
patient.first_namepatient.last_namepatient.emailpatient.phonepatient.medicarepatient.medicare_reference_numberpatient.address_1patient.address_2patient.citypatient.statepatient.post_codepatient.countrypatient.date_of_birthpatient.appointment_start(ISO 8601 string)patient.practitioner_id
Content schema notes:
content.sectionsis an array of sections from the Cliniko template.content.sections[].questions[].nameis the stable key and label.content.sections[].questions[].typecan betext,textarea,checkboxes, orradiobuttons.content.sections[].questions[].requiredis a boolean.- For
text/textarea, sendanswer(string). - For
checkboxes/radiobuttons, sendanswers(array of{ value, selected }). - For
radiobuttons, only oneanswers[].selectedcan betrue. selected: falseentries are sanitized server-side (removed before Cliniko payload dispatch).- If the template enables "other", include
otheras{ enabled: true, selected: true|false, value: "..." }. signaturequestions are not allowed in payloads.
Headless API Reference
These are the REST endpoints the headless helpers call. They are registered under the WordPress REST API and protected by same-origin checks plus route-specific tokens where needed.
Base path:
/wp-json/v1
Auth:
- Booking-attempt mutation routes (
/wp-json/v2/booking-attempts/*) also require the attempt token viaattempt_tokenorX-ES-Attempt-Token. - Patient-access routes require the patient access token via
patient_access_token,access_token, orX-ES-Patient-Access-Token.
Response conventions:
- Cliniko data endpoints return
{ success: true, data: ... }on success. - Payment endpoints return
{ status: "success", payment: ..., scheduling: ... }on success. - Errors return a
messageand may include anerrorsarray with{ field, label, code, detail }.
GET /practitioners
Lists practitioners for an appointment type.
Query params:
appointment_type_id(required). Aliases:module_id,moduleId.
Example request:
GET /wp-json/v1/practitioners?appointment_type_id=123
Returns:
successbooleandata.appointment_type_idstringdata.practitionersarray of{ id, name }(inactive/hidden practitioners are filtered when possible)
Example response:
{
"success": true,
"data": {
"appointment_type_id": "123",
"practitioners": [
{ "id": "456", "name": "Jane Smith" }
]
}
}
GET /appointment-type
Returns appointment type details and computed payment amount.
Query params:
appointment_type_id(required). Aliases:module_id,moduleId.refresh(optional).1|true|yesbypasses cached Cliniko response.
Example request:
GET /wp-json/v1/appointment-type?appointment_type_id=123
Returns:
successbooleandata.appointment_type_idstringdata.namestringdata.descriptionstringdata.duration_in_minutesintegerdata.amount_centsintegerdata.amountstring (decimal)data.currencystringdata.payment_requiredboolean
GET /patient-form-template
Returns a patient form template (sections) plus a submission-ready skeleton.
Query params:
patient_form_template_id(required). Aliases:template_id,id.refresh(optional).1|true|yesbypasses cached Cliniko response.
Example request:
GET /wp-json/v1/patient-form-template?patient_form_template_id=999
Returns:
successbooleandata.patient_form_template_idstringdata.namestringdata.sectionsarraydata.submission_templateobject
GET /appointment-calendar
Returns an HTML calendar grid (plus labels) for a given appointment type and optional practitioner.
Query params:
appointment_type_id(required). Aliases:module_id,moduleId.practitioner_id(optional). If omitted, the first practitioner for the appointment type is used.month(optional). Format:YYYY-MM. Defaults to the current month.
Example request:
GET /wp-json/v1/appointment-calendar?appointment_type_id=123&practitioner_id=456&month=2026-02
Returns:
successbooleandata.month_labelstring (human-readable month)data.month_keystring (formatYYYY-MM)data.grid_htmlstring (calendar day grid HTML)data.practitioner_idstringdata.appointment_type_idstring
Example response:
{
"success": true,
"data": {
"month_label": "February 2026",
"month_key": "2026-02",
"grid_html": "<div class=\"calendar-day ...\">...</div>",
"practitioner_id": "456",
"appointment_type_id": "123"
}
}
GET /available-times
Returns available appointment start times for a given date range.
Query params:
appointment_type_id(required). Aliases:module_id,moduleId.from(required). Format:YYYY-MM-DD.to(required). Format:YYYY-MM-DD.practitioner_id(optional). If omitted, the first practitioner for the appointment type is used.page(optional). Defaults to1.per_page(optional). Defaults to100, max100.
Example request:
GET /wp-json/v1/available-times?appointment_type_id=123&from=2026-02-10&to=2026-02-10&practitioner_id=456
Returns:
successbooleandata.available_timesarray of{ appointment_start }data.total_entriesintegerdata.links.self|next|previousstringsdata.appointment_type_idstringdata.practitioner_idstringdata.fromstringdata.tostring
Example response:
{
"success": true,
"data": {
"available_times": [
{ "appointment_start": "2026-02-10T01:30:00Z" }
],
"total_entries": 12,
"links": {
"self": "https://example.com/wp-json/v1/available-times?...",
"next": null,
"previous": null
},
"appointment_type_id": "123",
"practitioner_id": "456",
"from": "2026-02-10",
"to": "2026-02-10"
}
}
POST /send-patient-form
Queues a patient form submission to Cliniko (no payment). This is also the endpoint used for the Cliniko iframe flow.
Body (JSON):
patient_form_template_id(required)patient.email(required)patient.patient_booked_time(required). ISO 8601 UTC string (example:2026-02-10T01:30:00Z)content.sections(required). Use the same structure you get from the headless template JSON.moduleId(optional)
Example request body:
{
"moduleId": "123",
"patient_form_template_id": "999",
"patient": {
"email": "jane@example.com",
"patient_booked_time": "2026-02-10T01:30:00Z"
},
"content": {
"sections": [
{
"name": "Section Name",
"questions": [
{
"name": "Question Label",
"type": "text",
"required": true,
"answer": "Free text answer"
}
]
}
]
}
}
Notes:
signaturequestions are not allowed incontent.- Required questions must include valid answers. For radiobuttons, only one option may be selected.
selected: falsevalues incheckboxes/radiobuttonsare stripped server-side before Cliniko submission.- If an "other" option is selected, it must include a non-empty
other.value.
Success response (HTTP 202):
{
"success": true,
"message": "Patient form creation has been queued.",
"queued": {
"payload_key": "cliniko_pf_job_payload_...",
"status": "queued"
}
}
Error response (HTTP 400):
{
"success": false,
"message": "Invalid request parameters.",
"errors": [
{ "field": "patient.email", "label": "Email", "code": "invalid", "detail": "Email is invalid or missing." }
]
}
POST /payments/charge (Stripe)
Charges Stripe and queues scheduling. Also handles zero-cost bookings (no Stripe token required when payment is not required).
Body (JSON):
moduleId(required)patient_form_template_id(required)stripeToken(required if payment is required; must start withtok_orpm_)patient(required)patient.first_name,patient.last_name,patient.email(required)patient.practitioner_id(required, numeric Cliniko ID)patient.medicare(required, 9 digits)patient.medicare_reference_number(required, single digit 1-9)patient.date_of_birth(optional,YYYY-MM-DDif provided)patient.appointment_start(optional, ISO 8601 datetime if provided)patient.appointment_date(optional,YYYY-MM-DDif provided)content(required; validated to match Cliniko patient form structure)signature_attachment_id(optional)
Example request body:
{
"moduleId": "123",
"patient_form_template_id": "999",
"stripeToken": "tok_visa",
"patient": {
"first_name": "Jane",
"last_name": "Doe",
"email": "jane@example.com",
"practitioner_id": "1547537765724333824",
"medicare": "1234 56789",
"medicare_reference_number": "1",
"date_of_birth": "1992-02-23",
"appointment_start": "2026-02-12T05:50:00Z"
},
"content": {
"sections": []
}
}
Success response:
{
"status": "success",
"payment": {
"id": "ch_...",
"amount": 12500,
"currency": "aud",
"receipt_url": "https://...",
"card_last4": "4242",
"brand": "visa"
},
"scheduling": { "status": "queued" }
}
Notes:
- If the appointment type does not require payment,
stripeTokencan be omitted and the response will include anullpayment id with amount0. contentis accepted as-is; keep its structure aligned with the template.
POST /tyrohealth/sdk-token
Returns a short-lived Tyro Health Partner SDK token.
Request body: none
Success response:
{ "token": "..." }
POST /tyrohealth/invoice
Returns pricing metadata for the Tyro Health SDK.
Body (JSON):
moduleId(required)
Example request body:
{
"moduleId": "123"
}
Success response:
{
"success": true,
"data": {
"chargeAmount": "125.00",
"invoiceReference": "Appointment Type Name",
"providerNumber": "123456"
}
}
POST /tyrohealth/charge
Queues scheduling after a Tyro Health transaction.
Body (JSON):
moduleId(required)patient_form_template_id(required)tyroTransactionIdortransactionId(required when payment is required). This must be the Tyro SDK transaction_idreturned fromrenderCreateTransactionon success, not the human-facing transaction number.invoiceReference(optional)patient(required)content(optional; accepted but not validated here)signature_attachment_id(optional)
Example request body:
{
"moduleId": "123",
"patient_form_template_id": "999",
"tyroTransactionId": "66b9f1a4c4b3f9d1e8a12345",
"patient": {
"first_name": "Jane",
"last_name": "Doe",
"email": "jane@example.com"
},
"content": {
"sections": []
}
}
Success response:
{
"status": "success",
"payment": {
"id": "txn_...",
"amount": 12500,
"currency": "aud",
"receipt_url": null
},
"scheduling": { "status": "queued" }
}
Notes:
- If the appointment type does not require payment,
tyroTransactionIdcan be omitted and the response will include anullpayment id with amount0. contentis accepted as-is; keep its structure aligned with the template.- The backend verifies the transaction's approved/completed status, charged amount, invoice reference, and approved payment total before it marks the attempt as paid.
Async Processing
The plugin uses Action Scheduler for background jobs.
Primary pattern:
- Frontend submits structured payload.
- Controller validates and persists payload metadata.
- Worker processes booking and patient form tasks.
If Action Scheduler is not available, WP-Cron is used as fallback.
Security Notes
- API credentials are stored in WordPress options with capability checks.
- Inputs are sanitized/validated before processing.
- Widget output uses escaped attributes/content.
- Stripe secret key is never exposed in frontend payloads.
- Webhook signing secrets are stored server-side and encrypted when the plugin secret option helpers are available.
- Webhook payloads intentionally omit clinical form answers, Medicare data, card details, and access tokens.
Troubleshooting
Calendar not visible in custom form wizard
- Confirm appointment source is
custom_form. - Confirm scheduling mode is
calendar. - Confirm the wizard includes the
[data-appointment-selection]block.
Payment step does not open
- Confirm selected gateway is enabled in widget settings.
- Confirm final wizard action triggers gateway flow instead of direct form submit.
- Confirm only the selected gateway assets are loaded.
Practitioner not populated
- Confirm practitioner endpoint is reachable from site context.
- Confirm appointment type is correctly configured.
Changelog
See CHANGELOG.md for release history.
Contributing
Pull requests are welcome. For major changes, open an issue first with:
- use case
- expected behavior
- implementation notes
License
MIT