Skip to content

Beta program: three months of DeMark free, no card. 25 of 25 spots left, sign-ups close November 16. Join the beta

DeMark

Developers

Connect the website you already have

Keep your site on WordPress, Wix, Squarespace, Shopify or your own code. Paste one snippet and your visitors book, ask for a quote, chat, sign up for events or buy, straight into DeMark. Developers get a documented API and signed webhooks.

01 / 11

Before you start

Pasting a snippet takes a few minutes. What takes longer is the account behind it: tick this list first and the widget works the first time. Each item says where it lives in DeMark and what you see when it is missing.

  1. An active account on Essential or Complete

    Both plans connect a website. Essential has one key in total, public or secret, and no webhooks. Complete has unlimited keys and webhooks.

    Where: /app/settings/membership

    If it is missing: the API answers 403 plan_required or service_unavailable.

  2. Signed in as the owner

    Only the business owner sees Settings › Developers and can create or revoke keys, edit the allowed domains and add webhooks.

    Where: /app/settings/team

    If it is missing: team members get a page saying they are not allowed.

  3. API & widgets turned on

    In Settings › Tools, turn on API & widgets. A new account usually has it off, unless you said during setup that you already have a website.

    Where: /app/settings/tools

    If it is missing: Settings › Developers says that API & widgets is turned off.

  4. Booking: services and opening hours

    Appointments on in Settings › Tools, at least one service with its duration, and opening hours with free time in the coming days.

    Where: /app/settings/tools, /app/services, /app/availability

    If it is missing: the widget has no service or no time to offer, and GET /availability comes back empty.

  5. Requests: a contact email

    Requests need nothing turned on; they all land in Requests. Fill in the contact email in Settings › Business so each new one reaches you.

    Where: /app/settings/business, /app/requests

    If it is missing: requests are saved, but nobody is told.

  6. Chat: the assistant turned on

    Chat assistant on in Settings › Tools and enabled on its own page.

    Where: /app/settings/tools, /app/chatbot

    If it is missing: the widget shows Not available and the API answers 404 chat_disabled.

  7. Events: an event with a date to come

    Events on in Settings › Tools and at least one active event with a future date.

    Where: /app/settings/tools, /app/events

    Tool off: the widget shows Not available. On but with no date to come: the list is empty (data: []).

  8. Shop: turned on, with a product

    Online shop on in Settings › Tools and at least one active product in Shop.

    Where: /app/settings/tools, /app/shop

    Tool off: the API answers 404 shop_disabled and the widget shows its link. On but with no product: data: [].

  9. Your website's domains

    In Settings › Developers, list every hostname that will show a widget, exactly as it appears in the address bar.

    Where: /app/settings/developers

    If it is missing: the API answers 403 origin_not_allowed and the iframe stays blank.

  10. The right key in the right place

    A public key for the browser, a secret key only on a server. Details just below.

    Where: /app/settings/developers

    In the wrong place: 403 secret_key_in_browser or 403 secret_key_required.

  11. Confirmation emails in the queue

    After a test booking, open Emails: the confirmation should be there with its status. Customers get the same emails whether they book on your site or on DeMark.

    Where: /app/emails

    If it is missing: the booking exists but the customer gets no confirmation.

  12. Webhooks: an HTTPS address (Complete)

    Only if DeMark should call your server: a public HTTPS address that answers within 10 seconds.

    Where: /app/settings/developers/webhooks

    If it is missing: deliveries fail and are retried; the delivery history shows why.

Allowed domains, exactly

  • Hostnames only, such as www.your-salon.ca, without the scheme or a path. If you paste a full link, only its hostname is kept.
  • www.your-salon.ca and your-salon.ca are two different hosts: add both if both open your site. Staging, wixsite.com and myshopify.com addresses count as hosts too.
  • The API ignores the scheme and the port. While testing on your computer, localhost (or 127.0.0.1) covers every port; remove it when you are done.
  • The iframe version only loads on https pages of the listed hostnames and their subdomains. Plain http is accepted only for localhost and 127.0.0.1.

Which key goes where

dmk_pk_ public key: the browser

For widgets and fetch calls in your pages. It only works from your allowed domains and can only read services, availability, products and events, and create bookings, requests, carts, registrations and chat messages. Anyone can see it in your page source; that is expected.

dmk_live_ secret key: your server only

Keep it in an environment variable or in wp-config.php. Never in HTML, a WordPress block, a theme file or a code repository. The API refuses it when the call comes from a browser.

iframe no key

The iframe version needs no key: it loads your DeMark widget by its address, and only on your allowed domains.

How long does it take? It depends on this list, not on the code. With the list done, pasting and testing a widget is quick; a brand-new account first needs its services, hours, products or events entered.

02 / 11

Connect your existing site in 3 steps

With the list above done, three steps and no developer needed. Every step can be undone.

  1. Create a public key and add your domain

    In DeMark, open Settings › Developers. Create a public key (it starts with dmk_pk_) with the permission of each widget you will use, and add your website's domain, for example www.your-salon.ca. The key only works on the domains you list.

    Open Settings › Developers

  2. Paste the snippet where the widget should appear

    Copy the code below (Settings › Developers shows it with your key already filled in) and paste it into an HTML block on your page. The next section shows where that block is on each platform.

    HTML
    <script src="https://demarkstudio.ca/embed/booking.js" data-key="dmk_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" data-lang="en" defer></script>
    <div data-demark="booking"><a href="https://demarkstudio.ca/YOUR-SLUG/book?lang=en">Book online</a></div>
  3. Test it

    Open your page in a private window and book a test appointment. It shows up in Appointments and its confirmation in Emails. Then press Cancel booking in the widget: a DeMark page shows the booking and asks you to confirm. That is the whole cycle.

Essential includes one key and every widget. Complete adds unlimited keys and webhooks.

Can't add code on your plan?

Every business already has a booking page on DeMark. A plain link or button to it works on any website builder, with or without JavaScript. Replace YOUR-SLUG with your business's address in DeMark: the part before /book in your booking page link.

HTML, no JavaScript
<a href="https://demarkstudio.ca/YOUR-SLUG/book?lang=en">Book online</a>

03 / 11

Where to paste it, platform by platform

The drawings show where the HTML block lives in each editor. Menu names change from time to time; if yours looks different, the official help link under each guide is kept up to date by the platform.

WordPress

Diagram of the WordPress block editor: the + inserter opens the block list, and the Custom HTML block sits in the page content.
WordPress: block inserter (+), then Custom HTML.
  1. Edit the page where the widget should appear.
  2. Click the + block inserter and search for Custom HTML.
  3. Paste the snippet into the block.
  4. Click Preview to see it working, then Update or Publish.

Self-hosted WordPress has no restriction. On WordPress.com, scripts and iframes in a Custom HTML block need a plan that allows plugins; on other plans, use the plain link.

WordPress help: the Custom HTML block

Custom HTML block, English and French pages

WordPress: Custom HTML block
<!-- English page -->
<script src="https://demarkstudio.ca/embed/booking.js" data-key="dmk_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" data-lang="en" defer></script>
<div data-demark="booking"><a href="https://demarkstudio.ca/YOUR-SLUG/book?lang=en">Book online</a></div>

<!-- French page -->
<script src="https://demarkstudio.ca/embed/booking.js" data-key="dmk_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" data-lang="fr" defer></script>
<div data-demark="booking"><a href="https://demarkstudio.ca/YOUR-SLUG/book?lang=fr">Réserver en ligne</a></div>

Mini plugin: the key in the settings, a shortcode for editors

Save this file in wp-content/plugins/demark-widgets/ and activate it (WordPress 6.3 or later). The public key and your DeMark address live in Settings › DeMark, each script is loaded once with wp_enqueue_script, and editors type a shortcode instead of pasting code. If your server also calls the API, the secret key goes in wp-config.php and is only used in PHP.

wp-content/plugins/demark-widgets/demark-widgets.php
<?php
/**
 * Plugin Name: DeMark widgets
 * Description: [demark widget="booking|request|chat|events|shop" lang="en|fr"] with the key kept in Settings > DeMark.
 * Version: 1.0.0
 * Requires at least: 6.3
 */

defined('ABSPATH') || exit;

const DEMARK_BASE = 'https://demarkstudio.ca';
const DEMARK_WIDGETS = ['booking', 'request', 'chat', 'events', 'shop'];

add_action('admin_init', function () {
    register_setting('demark', 'demark_public_key', [
        'type' => 'string',
        'sanitize_callback' => function ($value) {
            $value = trim((string) $value);
            return preg_match('/^dmk_pk_[A-Za-z0-9]{16,64}$/', $value) ? $value : '';
        },
    ]);
    register_setting('demark', 'demark_slug', ['type' => 'string', 'sanitize_callback' => 'sanitize_title']);
});

add_action('admin_menu', function () {
    add_options_page('DeMark', 'DeMark', 'manage_options', 'demark', function () {
        ?>
        <div class="wrap">
          <h1>DeMark</h1>
          <form method="post" action="options.php">
            <?php settings_fields('demark'); ?>
            <p><label>Public key (dmk_pk_...)<br>
              <input name="demark_public_key" class="regular-text" value="<?php echo esc_attr(get_option('demark_public_key')); ?>"></label></p>
            <p><label>Business address in DeMark (slug)<br>
              <input name="demark_slug" class="regular-text" value="<?php echo esc_attr(get_option('demark_slug')); ?>"></label></p>
            <?php submit_button(); ?>
          </form>
        </div>
        <?php
    });
});

add_shortcode('demark', function ($atts) {
    $atts = shortcode_atts(['widget' => 'booking', 'lang' => '', 'service' => '', 'type' => ''], $atts, 'demark');
    $widget = in_array($atts['widget'], DEMARK_WIDGETS, true) ? $atts['widget'] : 'booking';
    $lang = in_array($atts['lang'], ['en', 'fr'], true) ? $atts['lang'] : (str_starts_with(get_locale(), 'fr') ? 'fr' : 'en');
    $slug = rawurlencode((string) get_option('demark_slug'));
    // Loaded once per widget type, deferred, in the footer.
    wp_enqueue_script('demark-' . $widget, DEMARK_BASE . '/embed/' . $widget . '.js', [], null,
        ['strategy' => 'defer', 'in_footer' => true]);
    // Without JavaScript the link still works.
    $fallback = $widget === 'booking'
        ? DEMARK_BASE . '/' . $slug . '/book?lang=' . $lang
        : DEMARK_BASE . '/embed/' . $slug . '/' . $widget . '?lang=' . $lang;
    $extra = '';
    if ($atts['service'] !== '') {
        $extra .= ' data-service="' . esc_attr(absint($atts['service'])) . '"';
    }
    if (in_array($atts['type'], ['quote', 'contact'], true)) {
        $extra .= ' data-type="' . esc_attr($atts['type']) . '"';
    }
    return sprintf('<div data-demark="%s" data-key="%s" data-lang="%s"%s><a href="%s">%s</a></div>',
        esc_attr($widget), esc_attr(get_option('demark_public_key')), esc_attr($lang), $extra,
        esc_url($fallback), $lang === 'fr' ? 'Ouvrir' : 'Open');
});

// Server side only: the secret key never reaches the browser.
function demark_api_get(string $path): ?array
{
    if (!defined('DEMARK_SECRET_KEY')) {
        return null;
    }
    $response = wp_remote_get(DEMARK_BASE . '/api/v1' . $path, [
        'timeout' => 10,
        'headers' => ['Authorization' => 'Bearer ' . DEMARK_SECRET_KEY],
    ]);
    if (is_wp_error($response) || wp_remote_retrieve_response_code($response) >= 400) {
        return null;
    }
    return json_decode(wp_remote_retrieve_body($response), true);
}
Shortcodes in a page, and wp-config.php
[demark widget="booking" lang="fr"]
[demark widget="request" type="quote"]

// wp-config.php: only if your server calls the API
define('DEMARK_SECRET_KEY', 'dmk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx');

Check the published page, not the editor: open it in a private window, view the source and look for data-demark and /embed/. If the script is missing, an optimisation plugin removed, combined or delayed it: exclude the /embed/ scripts from the combine, delay and minify options, then purge the cache. Caching the page itself is fine: widgets load their data live.

Wix

Diagram of the Wix editor: the Add panel on the left with the embed code item highlighted, and the embed box placed on the page.
Wix: Add Elements › Embed Code › Embed HTML.
  1. In the Wix editor, click Add Elements (+), choose Embed Code, then Embed HTML.
  2. In the element settings, choose Website address and paste the address from the iframe version below (the text in quotes after src=).
  3. Drag the box into place and set its height yourself: Wix does not resize it. The Widgets section gives a suggested height for each widget.
  4. Add both your own domain and your wixsite.com address in Settings › Developers, then publish.

Wix runs code pasted in Embed HTML inside its own frame, so the iframe version is the reliable choice on every plan, including the free one. On a Premium plan with your own domain, a developer can also load the script version as a Velo custom element.

Wix help: embedding a site or a widget

Embed HTML, Website address mode

Address to paste in Embed HTML
https://demarkstudio.ca/embed/YOUR-SLUG/booking?lang=en

Replace booking with request, chat, events or shop. Set the box height from the table in the Widgets section: Wix keeps the height you give it.

Floating chat on every page (Premium plan with your own domain)

Wix: Settings › Custom code › Body - end
<script src="https://demarkstudio.ca/embed/chat.js" data-key="dmk_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" data-lang="en" defer></script>
<div data-demark="chat"></div>

Squarespace

Diagram of the Squarespace editor: an insert point between sections opens the block menu, and the Code block sits on the page.
Squarespace: insert point (+), then Code.
  1. Edit the page and click an insert point (+) or Add block.
  2. Choose Code in the block menu.
  3. Paste the snippet, keep the mode on HTML and turn off Display source.
  4. Apply, save, and check the live page: the editor preview doesn't always run scripts.

On plans where Code blocks don't run JavaScript, paste the iframe version into the Code block instead. If iframes aren't allowed either, use the plain link.

Squarespace help: Code blocks

Squarespace: Code block, HTML mode
<script src="https://demarkstudio.ca/embed/request.js" data-key="dmk_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" data-lang="en" defer></script>
<div data-demark="request" data-type="quote"><a href="https://demarkstudio.ca/embed/YOUR-SLUG/request?lang=en">Ask for a quote</a></div>

Shopify

Diagram of the Shopify theme editor: the section list on the left with Add section and Custom Liquid highlighted, and the new section in the preview.
Shopify: Online Store › Themes › Customize › Add section › Custom Liquid.
  1. In your Shopify admin, go to Online Store › Themes and click Customize.
  2. Open the template where the widget should go and click Add section.
  3. Choose Custom Liquid and paste the snippet into the Liquid code field.
  4. Save. For a simple page instead, open Online Store › Pages, click Show HTML (<>) in the editor and paste the iframe version.

Add both your own domain and your myshopify.com address in Settings › Developers, so the widget also works before your domain is connected.

Shopify help: themes

The language follows the store's current language, so the same section works on your English and French storefronts.

Shopify: Custom Liquid section
{%- if request.locale.iso_code contains 'fr' -%}{%- assign demark_lang = 'fr' -%}{%- else -%}{%- assign demark_lang = 'en' -%}{%- endif -%}<script src="https://demarkstudio.ca/embed/events.js" data-key="dmk_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" data-lang="{{ demark_lang }}" defer></script>
<div data-demark="events"><a href="https://demarkstudio.ca/YOUR-SLUG/events?lang={{ demark_lang }}">Events</a></div>

Your own HTML

Diagram of a code editor: the DeMark snippet is added near the end of the page, just before the closing body tag.
Your own code: the script once before </body>, the element where the widget goes.
  1. Put the element with data-demark exactly where the widget should appear.
  2. Put the script tag once, just before </body>. It is deferred, so it never holds up your page.
  3. Upload the page and test it on the real domain: the key refuses domains you haven't added.

Does your site send a Content-Security-Policy? Allow the DeMark address from the snippet in script-src, style-src and connect-src for the script version, or in frame-src for the iframe version.

A complete HTML page
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Book online</title>
</head>
<body>
  <main>
    <!-- Where each widget appears. The link is for visitors without JavaScript. -->
    <div data-demark="booking"><a href="https://demarkstudio.ca/YOUR-SLUG/book?lang=en">Book online</a></div>
    <div data-demark="shop"><a href="https://demarkstudio.ca/YOUR-SLUG/shop?lang=en">Shop</a></div>
  </main>
  <!-- The scripts, once, before the closing body tag. -->
  <script src="https://demarkstudio.ca/embed/booking.js" data-key="dmk_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" data-lang="en" defer></script>
  <script src="https://demarkstudio.ca/embed/shop.js" data-key="dmk_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" data-lang="en" defer></script>
</body>
</html>

The iframe version

When a platform won't run scripts, use an iframe. It needs no key: it shows your DeMark widget in a frame, and it only loads on the domains you added in Settings › Developers. Replace booking with request, chat, events or shop for the other widgets.

HTML (iframe)
<iframe src="https://demarkstudio.ca/embed/YOUR-SLUG/booking?lang=en" title="Book online" loading="lazy" height="900" style="display:block;width:100%;border:0"></iframe>

Suggested iframe heights per widget

04 / 11

Five widgets, one pattern

Each widget is a script plus an element that marks where it appears. It loads after your page, speaks English or French, and sends everything to the same place in DeMark as your own booking page.

booking Online booking

Services, staff and real availability, with the deposit, reminder and no-show rules you set in DeMark. Add data-service to open straight on one service.

HTML
<script src="https://demarkstudio.ca/embed/booking.js" data-key="dmk_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" data-lang="en" defer></script>
<div data-demark="booking" data-service="SERVICE_ID"></div>

request Quote and contact requests

A short form with an optional attachment. Each request lands in Requests along with the page it came from. data-type chooses quote or contact.

HTML
<script src="https://demarkstudio.ca/embed/request.js" data-key="dmk_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" data-lang="en" defer></script>
<div data-demark="request" data-type="quote"></div>

chat Chat assistant

Your DeMark chat assistant answers common questions and hands over to you when needed. It floats in a corner by default; data-inline places it inside the element instead.

HTML
<script src="https://demarkstudio.ca/embed/chat.js" data-key="dmk_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" data-lang="en" defer></script>
<div data-demark="chat" data-inline></div>

events Events and classes

Upcoming dates with seats left and registration, straight from Events in DeMark.

HTML
<script src="https://demarkstudio.ca/embed/events.js" data-key="dmk_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" data-lang="en" defer></script>
<div data-demark="events"></div>

shop Shop

Your products with variants and a cart. Payment happens on DeMark's secure checkout page, so card details never touch your site.

HTML
<script src="https://demarkstudio.ca/embed/shop.js" data-key="dmk_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" data-lang="en" defer></script>
<div data-demark="shop"></div>

Attributes

Attributes accepted on the script tag or on the element
AttributeWhat it does
data-keyYour public key (dmk_pk_...). Required.
data-langWidget language: en or fr. It translates the buttons and is sent with every catalog read and link. Without it, the widget follows the lang attribute of your page, and English otherwise.
data-baseThe DeMark address the widget talks to. Usually left out: it defaults to the address the script was loaded from.
data-serviceBooking only: the ID of the service to open directly (from GET /services).
data-typeRequest only: quote or contact.
data-inlineChat only: show the chat inside the element instead of a floating button.
data-accentAccent colour as a hex value, for example #1f5f8b. The CSS variables below give finer control.
data-radiusCorner radius, for example 8px.

Match your site's look

Widgets read six CSS variables. Set them in your own stylesheet, on the element or on any parent.

CSS on your site
[data-demark] {
  --dmk-accent: #1f5f8b;
  --dmk-accent-contrast: #ffffff;
  --dmk-bg: #ffffff;
  --dmk-text: #16232e;
  --dmk-radius: 8px;
  --dmk-font: "Your Site Font", system-ui, sans-serif;
}

Choose an accent-contrast colour that stays readable on your accent (a ratio of 4.5:1 or more). White text on a light accent is the usual trap.

The language goes all the way

data-lang is not only for the buttons. The widget sends it with every catalog read (services, events, products), so names and descriptions come in that language, from the texts you entered in DeMark, and adds it to the links it opens: managing or cancelling a booking, event details and checkout. Use the same snippet on each language page with its own data-lang.

Iframe heights

Content height of each iframe page in pixels, measured on 15 September 2026 in Edge with the demo data: tallest step, 2 services, 1 weekly event, 9 products. In brackets, the same page once the public key enhancement has loaded. Start from the suggested height and check it with your own catalog.

Measured content height and suggested iframe height, per widget
WidgetPhone, 390 pxDesktop, 1440 pxSuggested heightWhat it does
booking 346 / 889 / 813 354 / 655 / 668 900 px on phones, 700 px on desktop Steps: service, times, details. The times step is the tallest and grows with the free times shown.
request 763 (880) 594 (711) 960 px on phones, 760 px on desktop Field errors add a few lines.
chat 530 (403) 449 (403) 600 px The conversation scrolls inside the frame.
events 378 (3364) 314 (2508) 720 px Grows with every date you publish: accept an inner scroll or use the script version.
shop 1658 (2464) 839 (1103) 900 px Grows with every product: accept an inner scroll or use the script version.

An iframe does not resize itself. DeMark does not send its height to your page, so the height you set is the height it keeps: taller content scrolls inside the frame, shorter content leaves empty space, and the height changes from one step to the next. The script version is part of your page and grows with its content: prefer it wherever your platform allows scripts.

Two heights with CSS on your own site
<iframe class="demark-frame demark-booking" src="https://demarkstudio.ca/embed/YOUR-SLUG/booking?lang=en" title="Book online" loading="lazy"></iframe>
<style>
  .demark-frame { display: block; width: 100%; border: 0; }
  .demark-booking { height: 900px; }
  @media (min-width: 761px) { .demark-booking { height: 700px; } }
</style>

05 / 11

API reference

A JSON API over HTTPS for anything the widgets don't cover: your own booking form, a sync with your CRM, an app. The full reference, with every field, is generated from the code itself.

Authentication

Send your key in the Authorization header with every request. Keys are created, named and revoked in Settings › Developers.

HTTP request
GET https://demarkstudio.ca/api/v1/me
Authorization: Bearer dmk_live_YOUR_SECRET_KEY
  • Secret key (dmk_live_...): for your server only. It can do everything its permissions allow, including listing customers, bookings and orders.
  • Public key (dmk_pk_...): for the browser, and only from the domains you allowed. It reads services, availability, products and events, and creates bookings, requests, carts, event registrations and chat messages. Nothing else.

Resources

Every path is relative to https://demarkstudio.ca/api/v1

API v1 endpoints and the key each one accepts
RequestKeyWhat it does
GET /me Public or secret The business the key belongs to, its plan and the key's permissions.
GET /services Public or secret Bookable services with duration, price and staff.
GET /availability?service=&date=YYYY-MM-DD&staff= Public or secret Free start times for a service on a date, optionally for one staff member.
POST /bookings Public or secret Create a booking. Answers 201 with id, token, status, start, end, service, manage_url and cancel_url.
GET /bookings/{id}?token= Public or secret Read one booking. From the browser, pass the token returned when it was created.
DELETE /bookings/{id}?token= Public or secret Cancel a booking under your cancellation policy. From the browser, pass the token.
GET /bookings Secret only List bookings, page by page.
POST /requests Public or secret Create a quote, contact or support request, with an optional attachment.
GET /requests Secret only List requests, page by page.
GET /customers Secret only List customers, page by page.
POST /customers Secret only Create a customer. Requires consent: true. Answers 200 with created: false if the email already exists.
GET /products Public or secret Shop products with their variants.
POST /carts Public or secret Create a cart from items and get a checkout_url for DeMark's payment page.
GET /orders Secret only List orders, page by page.
POST /orders Secret only Create an order from your own system.
GET /events?from= Public or secret Upcoming events and their dates, starting from a day (from=YYYY-MM-DD).
POST /events/{event_id}/registrations Public or secret Register someone for one date of an event, with a number of seats.
POST /chat Public or secret Send a message to your chat assistant. Answers with session, reply, options and handoff.
GET /webhooks/events Secret only The webhook event types you can subscribe to.

Request and response bodies

Bodies are JSON. Send times in ISO 8601 with an offset or Z (without one, the business's local time is assumed); they come back with the business's offset. Dates are YYYY-MM-DD. Fields marked optional in the full reference can be left out.

POST /api/v1/bookings
{"service": 12, "start": "2026-10-02T14:30:00-03:00",
 "name": "Marie Tremblay", "email": "marie.tremblay@mail.test", "phone": "+15065550123",
 "staff": 3, "notes": "First visit", "lang": "fr", "consent": true}

201 Created
{"id": 981, "status": "confirmed",
 "start": "2026-10-02T14:30:00-03:00", "end": "2026-10-02T15:15:00-03:00",
 "service": {"id": 12, "name": "Coupe"}, "staff": {"id": 3, "name": "Ana"},
 "customer": {"name": "Marie Tremblay", "email": "marie.tremblay@mail.test", "phone": "+15065550123"},
 "notes": "First visit", "lang": "fr", "source": "api", "created_at": "2026-10-01T15:04:11-03:00",
 "token": "BOOKING_TOKEN", "payment_url": null,
 "manage_url": "https://demarkstudio.ca/citas/BOOKING_TOKEN",
 "cancel_url": "https://demarkstudio.ca/citas/BOOKING_TOKEN/cancel"}
POST /api/v1/requests
{"type": "quote", "name": "Marc Leblanc", "email": "marc.leblanc@mail.test", "phone": "+15065550199",
 "subject": "Kitchen renovation", "message": "Can you quote a 12 x 14 ft kitchen?",
 "attachment": {"filename": "plan.pdf", "content_type": "application/pdf", "content_base64": "JVBERi0xLjcK..."},
 "lang": "en", "page_url": "https://www.your-salon.ca/quote"}
  • POST /bookings also takes policy_accepted: true when the service asks for a deposit; payment_url then tells you where the customer pays it. cancel_url is the API address to send a DELETE to; inside your cancellation window that DELETE answers 409 with cancellation_window.
  • POST /customers takes name, email, an optional phone and consent: true. Without consent the request is refused: it is your record that the person agreed.
  • POST /carts takes items, each with product, qty and an optional variant, and answers with checkout_url: send the visitor there to pay.
  • POST /events/EVENT_ID/registrations takes occurrence, name, email and, optionally, phone and seats.
  • POST /chat takes message, and optionally session and lang. Keep the session from the first reply and send it back to continue the same conversation; handoff: true means the assistant passed it on to you.

Errors

Errors always have the same shape. code is one of the values below; message is a short, stable reason such as slot_unavailable, cancellation_window or origin_not_allowed; fields, on validation errors, names the fields that failed. Show your own words to people and branch on code and message in your program.

422 Unprocessable Entity
{"error": {"code": "validation", "message": "invalid_request",
           "fields": {"email": "invalid", "consent": "required"}}}
HTTP status and error code
StatuscodeWhat it does
401unauthorizedThe key is missing, mistyped or revoked.
403forbiddenThe key can't do this: a public key on a secret-only endpoint, a missing permission, a domain that isn't allowed, a secret key sent from a browser, or a feature outside your plan.
404not_foundThe resource doesn't exist, or belongs to another business.
409conflictThe action clashes with the current state, for example a time slot that was just taken.
422validationSome fields are missing or invalid; see fields.
429rate_limitedToo many requests with this key. Wait for the number of seconds in Retry-After.
500internalSomething broke on our side. It is logged; try again a little later.

Rate limits

Each key can make 600 requests per minute. Every response tells you where you stand; after a 429, wait for the seconds in Retry-After before trying again.

Response headers
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1790000060
Retry-After: 60

{"error": {"code": "rate_limited", "message": "rate_limited"}}

Pagination

Lists answer with data and next_cursor, newest first. limit goes from 1 to 100 (25 by default). Pass next_cursor back as cursor to get the next page; when it is null, you have everything. The services list comes in one piece.

Paging through a list
GET https://demarkstudio.ca/api/v1/bookings?limit=50
{"data": [ ... ], "next_cursor": "CURSOR"}

GET https://demarkstudio.ca/api/v1/bookings?limit=50&cursor=CURSOR
{"data": [ ... ], "next_cursor": null}

Calling the API from a browser (CORS)

Before calling the API from your page, the browser sends a preflight request. DeMark only accepts it when the page's origin is one of the domains allowed for your business, so a public key copied onto someone else's site doesn't work there.

Models

The objects the API returns and the webhooks send. Dates carry your business offset, money is a decimal string and every value is the public English one.

Booking
Fields of Booking
AttributeTypeRequiredWhat it does
idintegerYes
statusstringYespending, confirmed, cancelled, completed or no_show.
startstringYesISO 8601 with the business offset.
endstringYes
serviceServiceRef | nullYesnull when the service was deleted.
staffStaffRef | nullYesnull when anyone can do it.
locationLocationRef | nullYesWhere it takes place (GET /locations). start and end carry the business offset; the location time zone is here.
customerCustomerRefYes
notesstring | nullYes
langstring | nullYesen or fr: the language of the booking and its emails.
sourcestring | nullYesWhere it was made: web, api, panel, portal...
created_atstring | nullYes
Real example
{
  "id": 1,
  "status": "confirmed",
  "start": "2026-10-09T09:00:00-03:00",
  "end": "2026-10-09T09:45:00-03:00",
  "service": {
    "id": 3,
    "name": "Coupe"
  },
  "staff": {
    "id": 1,
    "name": "Julie"
  },
  "location": {
    "id": 2,
    "name": "Salon Ana",
    "timezone": "America/Moncton"
  },
  "customer": {
    "name": "Marie Tremblay",
    "email": "marie.tremblay@mail.test",
    "phone": "506 555 0123"
  },
  "notes": "First visit",
  "lang": "fr",
  "source": "api",
  "created_at": "2026-10-02T23:24:52-03:00"
}
NewBooking
Fields of NewBooking
AttributeTypeRequiredWhat it does
idintegerYes
statusstringYespending, confirmed, cancelled, completed or no_show.
startstringYesISO 8601 with the business offset.
endstringYes
serviceServiceRef | nullYesnull when the service was deleted.
staffStaffRef | nullYesnull when anyone can do it.
locationLocationRef | nullYesWhere it takes place (GET /locations). start and end carry the business offset; the location time zone is here.
customerCustomerRefYes
notesstring | nullYes
langstring | nullYesen or fr: the language of the booking and its emails.
sourcestring | nullYesWhere it was made: web, api, panel, portal...
created_atstring | nullYes
tokenstringYesManagement token. Shown once: store it to read or cancel the booking.
manage_urlstringYesPage where the visitor manages the booking.
cancel_urlstringYesPage where the visitor confirms the cancellation (GET, no key). Servers cancel with DELETE /bookings/{id}?token=... or with a secret key.
payment_urlstring | nullYesDeposit or card checkout, when the service asks for it.
Real example
{
  "id": 1,
  "status": "confirmed",
  "start": "2026-10-09T09:00:00-03:00",
  "end": "2026-10-09T09:45:00-03:00",
  "service": {
    "id": 3,
    "name": "Coupe"
  },
  "staff": {
    "id": 1,
    "name": "Julie"
  },
  "location": {
    "id": 2,
    "name": "Salon Ana",
    "timezone": "America/Moncton"
  },
  "customer": {
    "name": "Marie Tremblay",
    "email": "marie.tremblay@mail.test",
    "phone": "506 555 0123"
  },
  "notes": "First visit",
  "lang": "fr",
  "source": "api",
  "created_at": "2026-10-02T23:24:52-03:00",
  "token": "IfnZPtmgcfpabtYFc_UU4AxdOWc1MHHGjdUU-jrbLAA",
  "manage_url": "https://demarkstudio.ca/citas/IfnZPtmgcfpabtYFc_UU4AxdOWc1MHHGjdUU-jrbLAA",
  "cancel_url": "https://demarkstudio.ca/citas/IfnZPtmgcfpabtYFc_UU4AxdOWc1MHHGjdUU-jrbLAA/cancel",
  "payment_url": null
}
Request
Fields of Request
AttributeTypeRequiredWhat it does
idintegerYes
typestringYesquote, contact or support (also lead, booking, question, event, table_booking, other for requests that came from other channels).
statusstringYesnew, open, waiting_customer, resolved or closed.
subjectstringYes
messagestringYesWhat the customer wrote, without page or file lines.
customerCustomerRefYes
page_urlstring | nullYesPage the request was sent from, when known.
attachmentsarray[Attachment]YesEmpty when there are none.
langstring | nullYes
sourcestringYesapi, web, site, chat, email...
created_atstringYes
Real example
{
  "id": 1,
  "type": "quote",
  "status": "new",
  "subject": "Wedding party, 6 people",
  "message": "Hair and make-up for six people on a Saturday morning.",
  "customer": {
    "name": "Marc Leblanc",
    "email": "marc.leblanc@mail.test",
    "phone": "506 555 0199"
  },
  "page_url": "https://www.your-salon.ca/weddings",
  "attachments": [
    {
      "id": 1,
      "filename": "inspiration.png",
      "content_type": "image/png",
      "size": 69,
      "url": "https://demarkstudio.ca/adjuntos/2/1?exp=1791253492&sig=c376ff521d8e24efa9fa372d1b46bd445c99acfc839ccd051a54d04c9dff4c3f",
      "expires_at": "2026-10-05T23:24:52-03:00"
    }
  ],
  "lang": "fr",
  "source": "api",
  "created_at": "2026-10-02T23:24:52-03:00"
}
Attachment
Fields of Attachment
AttributeTypeRequiredWhat it does
idintegerYes
filenamestringYes
content_typestringYes
sizeintegerYesBytes.
urlstringYesSigned download link; no key needed until expires_at.
expires_atstringYesISO 8601; ask for the request again to get a fresh url.
Real example
{
  "id": 1,
  "filename": "inspiration.png",
  "content_type": "image/png",
  "size": 69,
  "url": "https://demarkstudio.ca/adjuntos/2/1?exp=1791253492&sig=c376ff521d8e24efa9fa372d1b46bd445c99acfc839ccd051a54d04c9dff4c3f",
  "expires_at": "2026-10-05T23:24:52-03:00"
}
Customer
Fields of Customer
AttributeTypeRequiredWhat it does
idintegerYes
namestringYes
emailstringYes
phonestringYes
companystringYes
langstringYes
notesstringYes
marketing_consentbooleanYes
created_atstring | nullYes
Real example
{
  "id": 2,
  "name": "Marie Tremblay",
  "email": "marie.tremblay@mail.test",
  "phone": "506 555 0123",
  "company": "",
  "lang": "fr",
  "notes": "Prefers mornings",
  "marketing_consent": false,
  "created_at": "2026-10-02T23:24:52-03:00"
}
Order
Fields of Order
AttributeTypeRequiredWhat it does
idintegerYes
numberstringYes
statusstringYespending_payment, paid, preparing, ready, shipped, delivered, cancelled, refunded or paid_after_cancel.
customerCustomerRefYes
deliverystringYespickup, shipping, digital or empty.
subtotalstringYes
shippingstringYes
taxstringYes
totalstringYes
currencystringYes
itemsarray[OrderItem]Yes
created_atstringYes
Real example
{
  "id": 1,
  "number": "O-2026-0001",
  "status": "paid",
  "customer": {
    "name": "Lea Roy",
    "email": "lea.roy@mail.test",
    "phone": ""
  },
  "delivery": "pickup",
  "subtotal": "40.00",
  "shipping": "0.00",
  "tax": "0.00",
  "total": "40.00",
  "currency": "CAD",
  "items": [
    {
      "product_id": 1,
      "name": "Cedar candle",
      "price": "20.00",
      "qty": 2
    }
  ],
  "created_at": "2026-10-02T23:24:52-03:00"
}
Event
Fields of Event
AttributeTypeRequiredWhat it does
idstringYesOccurrence id: <event_id>-<YYYY-MM-DD>.
event_idintegerYes
titlestringYesIn the requested language.
descriptionstringYes
startstringYes
endstringYes
seats_leftinteger | nullYesnull = no capacity limit.
pricestring | nullYes
currencystringYes
urlstringYesEvent page in the requested language.
Real example
{
  "id": "1-2026-10-14",
  "event_id": 1,
  "title": "Atelier de soins capillaires",
  "description": "Deux heures pour apprendre une routine qui dure.",
  "start": "2026-10-14T18:00:00-03:00",
  "end": "2026-10-14T20:00:00-03:00",
  "seats_left": 12,
  "price": "25.00",
  "currency": "CAD",
  "url": "https://demarkstudio.ca/salon-ana/events/hair-care-workshop?ocurrencia=2026-10-14&lang=fr"
}
Service
Fields of Service
AttributeTypeRequiredWhat it does
idintegerYes
namestringYesIn the requested language.
descriptionstringYes
duration_minintegerYes
pricestring | nullYesDecimal as string, or null when the service has no price.
currencystringYes
staffarray[StaffRef]YesWho can do it; empty when the business has no team.
image_urlstring | nullYes
Real example
{
  "id": 3,
  "name": "Coupe",
  "description": "Lavage, coupe et coiffage.",
  "duration_min": 45,
  "price": "45.00",
  "currency": "CAD",
  "staff": [
    {
      "id": 1,
      "name": "Julie"
    }
  ],
  "image_url": null
}
Product
Fields of Product
AttributeTypeRequiredWhat it does
idintegerYes
namestringYes
descriptionstringYes
pricestring | nullYes
currencystringYes
image_urlstring | nullYes
urlstringYesProduct page in the requested language.
stockinteger | nullYesnull = unlimited.
variantsarray[object]YesActive variants (id, sku, price_cents, stock, options, active, available); empty when the product has no options.
Real example
{
  "id": 1,
  "name": "Bougie au cedre",
  "description": "",
  "price": "20.00",
  "currency": "CAD",
  "image_url": null,
  "url": "https://demarkstudio.ca/salon-ana/shop/cedar-candle?lang=fr",
  "stock": 10,
  "variants": []
}

06 / 11

Code examples

The same five tasks in four languages: list services, check availability, book, send a quote request with an attachment, and add a customer. Replace the IDs with your own: GET /services gives you the service IDs.

curl
curl
API=https://demarkstudio.ca/api/v1
KEY=dmk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

# 1. List services
curl -s "$API/services?lang=en" -H "Authorization: Bearer $KEY"

# 2. Free times for one service on one day
curl -s "$API/availability?service=12&date=2026-10-02" -H "Authorization: Bearer $KEY"

# 3. Book one of those times
curl -s -X POST "$API/bookings" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"service": 12, "start": "2026-10-02T14:30:00-03:00", "name": "Marie Tremblay",
       "email": "marie.tremblay@mail.test", "lang": "fr", "consent": true}'

# 4. Quote request with a file (base64)
curl -s -X POST "$API/requests" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d "{\"type\": \"quote\", \"name\": \"Marc Leblanc\", \"email\": \"marc.leblanc@mail.test\",
       \"message\": \"Kitchen renovation, plan attached.\",
       \"attachment\": {\"filename\": \"plan.pdf\", \"content_type\": \"application/pdf\",
                      \"content_base64\": \"$(base64 -w0 plan.pdf)\"}}"

# 5. Add a customer (secret key only; consent is required)
curl -s -X POST "$API/customers" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"name": "Julie Roy", "email": "julie.roy@mail.test", "consent": true}'
JavaScript

In the browser, use the public key. Adding a customer needs the secret key, so that part runs on your server.

JavaScript in the browser (public key)
// Load this file on your page with a script tag of type module.
const API = "https://demarkstudio.ca/api/v1";
const KEY = "dmk_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; // public key: fine in the browser, only works on your domains

async function demark(path, options = {}) {
  const res = await fetch(API + path, {
    ...options,
    headers: { "Authorization": "Bearer " + KEY, "Content-Type": "application/json" },
  });
  const body = await res.json();
  if (!res.ok) throw Object.assign(new Error(body.error.message), body.error);
  return body;
}

// 1. List services
const services = await demark("/services?lang=en");

// 2. Free times for one service on one day
const slots = await demark("/availability?service=12&date=2026-10-02");

// 3. Book one of those times
const booking = await demark("/bookings", {
  method: "POST",
  body: JSON.stringify({
    service: 12, start: "2026-10-02T14:30:00-03:00",
    name: "Marie Tremblay", email: "marie.tremblay@mail.test", lang: "fr", consent: true,
  }),
});

// 4. Quote request with the file chosen in the input with id plan
const file = document.querySelector("#plan").files[0];
const content = await new Promise((resolve, reject) => {
  const reader = new FileReader();
  reader.addEventListener("load", () => resolve(reader.result.split(",")[1]));
  reader.addEventListener("error", reject);
  reader.readAsDataURL(file);
});
await demark("/requests", {
  method: "POST",
  body: JSON.stringify({
    type: "quote", name: "Marc Leblanc", email: "marc.leblanc@mail.test",
    message: "Kitchen renovation, plan attached.", page_url: location.href,
    attachment: { filename: file.name, content_type: file.type, content_base64: content },
  }),
});
JavaScript on your server, Node 18+ (secret key)
// 5. Add a customer from your server: the secret key never reaches the browser
const res = await fetch("https://demarkstudio.ca/api/v1/customers", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.DEMARK_SECRET_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ name: "Julie Roy", email: "julie.roy@mail.test", consent: true }),
});
const customer = await res.json(); // 201 if new; 200 with created: false if the email already existed
PHP
PHP (curl)
<?php
const DEMARK_API = 'https://demarkstudio.ca/api/v1';

function demark(string $method, string $path, ?array $body = null): array
{
    $ch = curl_init(DEMARK_API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 10,
        CURLOPT_HTTPHEADER     => [
            'Authorization: Bearer ' . getenv('DEMARK_SECRET_KEY'),
            'Content-Type: application/json',
        ],
    ]);
    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    }
    $response = json_decode(curl_exec($ch), true);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($status >= 400) {
        throw new RuntimeException($response['error']['code'] . ': ' . $response['error']['message']);
    }
    return $response;
}

// 1. List services
$services = demark('GET', '/services?lang=en');

// 2. Free times for one service on one day
$slots = demark('GET', '/availability?' . http_build_query(['service' => 12, 'date' => '2026-10-02']));

// 3. Book one of those times
$booking = demark('POST', '/bookings', [
    'service' => 12, 'start' => '2026-10-02T14:30:00-03:00',
    'name' => 'Marie Tremblay', 'email' => 'marie.tremblay@mail.test', 'lang' => 'fr', 'consent' => true,
]);

// 4. Quote request with a file (base64)
$request = demark('POST', '/requests', [
    'type' => 'quote', 'name' => 'Marc Leblanc', 'email' => 'marc.leblanc@mail.test',
    'message' => 'Kitchen renovation, plan attached.',
    'attachment' => [
        'filename' => 'plan.pdf', 'content_type' => 'application/pdf',
        'content_base64' => base64_encode(file_get_contents('plan.pdf')),
    ],
]);

// 5. Add a customer (secret key only; consent is required)
$customer = demark('POST', '/customers', ['name' => 'Julie Roy', 'email' => 'julie.roy@mail.test', 'consent' => true]);
Python
Python (urllib)
import base64
import json
import os
import urllib.error
import urllib.request

API = "https://demarkstudio.ca/api/v1"
KEY = os.environ["DEMARK_SECRET_KEY"]


def demark(method, path, body=None):
    payload = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(API + path, data=payload, method=method, headers={
        "Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
    try:
        with urllib.request.urlopen(req, timeout=10) as resp:
            return json.load(resp)
    except urllib.error.HTTPError as exc:
        error = json.load(exc)["error"]
        raise RuntimeError(f"{error['code']}: {error['message']}") from None


# 1. List services
services = demark("GET", "/services?lang=en")

# 2. Free times for one service on one day
slots = demark("GET", "/availability?service=12&date=2026-10-02")

# 3. Book one of those times
booking = demark("POST", "/bookings", {
    "service": 12, "start": "2026-10-02T14:30:00-03:00",
    "name": "Marie Tremblay", "email": "marie.tremblay@mail.test", "lang": "fr", "consent": True})

# 4. Quote request with a file (base64)
with open("plan.pdf", "rb") as f:
    content = base64.b64encode(f.read()).decode()
request = demark("POST", "/requests", {
    "type": "quote", "name": "Marc Leblanc", "email": "marc.leblanc@mail.test",
    "message": "Kitchen renovation, plan attached.",
    "attachment": {"filename": "plan.pdf", "content_type": "application/pdf", "content_base64": content}})

# 5. Add a customer (secret key only; consent is required)
customer = demark("POST", "/customers", {"name": "Julie Roy", "email": "julie.roy@mail.test", "consent": True})

Prefer the requests library? requests.request(method, API + path, json=body, headers=..., timeout=10) does the same in fewer lines.

Files: attachment for one, attachments for up to 3 in total. PDF, JPEG, PNG, WebP or plain text, 5 MB each once decoded, and the bytes must match the declared type. The owner opens them in Requests; GET /requests and the request.created webhook give each file a signed url that works for 72 hours, after which you read the request again for a fresh one.

A complete webhook receiver

Python standard library only. It reads the raw bytes, verifies the signature, skips an event id it has already processed, answers 2xx quickly, and answers 500 when its own work fails so that DeMark retries. In production, keep the processed ids in a table with a unique column.

Python (http.server)
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer

SECRET = os.environ["DEMARK_WEBHOOK_SECRET"]
PORT = int(os.environ.get("PORT", "8080"))
PROCESSED = set()


def verify(secret, header, raw_body, tolerance=300):
    parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
    try:
        timestamp = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - timestamp) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))


def handle(event):
    print(event["type"], event["id"], json.dumps(event["data"])[:200], flush=True)


class Receiver(BaseHTTPRequestHandler):
    def answer(self, status):
        self.send_response(status)
        self.send_header("Content-Length", "0")
        self.end_headers()

    def do_POST(self):
        raw_body = self.rfile.read(int(self.headers.get("Content-Length") or 0))
        if not verify(SECRET, self.headers.get("X-DeMark-Signature", ""), raw_body):
            return self.answer(400)
        event = json.loads(raw_body)
        if event["id"] in PROCESSED:
            return self.answer(200)
        try:
            handle(event)
        except Exception:
            return self.answer(500)
        PROCESSED.add(event["id"])
        self.answer(204)


if __name__ == "__main__":
    HTTPServer(("", PORT), Receiver).serve_forever()

Test webhooks from your computer

DeMark only delivers to public HTTPS addresses, never to localhost or a private network, so put an HTTPS tunnel in front of the receiver.

  1. Run the receiver: DEMARK_WEBHOOK_SECRET=whsec_... PORT=8080 python receiver.py
  2. Open a tunnel to port 8080 with a tool such as cloudflared (cloudflared tunnel --url localhost:8080) or ngrok (ngrok http 8080). It prints a public HTTPS address.
  3. In Settings › Developers › Webhooks, add that address followed by /webhooks, tick the events, save, and copy the whsec_ secret (shown once). Restart the receiver with it.
  4. Press Send test: a ping event arrives and the delivery history shows a 2xx.
  5. To see a retry without waiting for a real booking, stop the receiver and press Send test again. The history shows the failed attempt and the next try one minute later; start the receiver again and the retry arrives with the same id and a new X-DeMark-Delivery.
  6. Resend, on any delivery in the history, sends the same body again with the same id: the receiver answers 200 without processing it twice.
  7. For real events without a customer, create an appointment in Appointments › New (booking.created) and cancel it (booking.cancelled): the dashboard sends the same events as your website and the API.

07 / 11

Webhooks

Instead of asking the API every few minutes, let DeMark tell your server when something happens. Give us an HTTPS address and we send a signed POST for each event you chose.

Webhooks are part of Complete. Set them up in Settings › Developers › Webhooks.

Events

Event types you can subscribe to
EventSent when
booking.createdA booking is confirmed, whether it came from a widget, the API, your booking page or the back office.
booking.cancelledA booking is cancelled, by the customer or by you.
request.createdA quote, contact or support request arrives.
order.paidAn order is paid.
customer.createdA new customer is added.
order.createdAn order is placed, before it is paid.
invoice.paidAn invoice is marked as paid.

What we send

A compact JSON body with the event id, its type, when it happened, your business and the object itself in data. The headers name the event and the delivery, and carry the signature.

What your endpoint receives
POST /your/webhook/endpoint
Content-Type: application/json
User-Agent: DeMark-Webhooks/1.0
X-DeMark-Event: booking.created
X-DeMark-Delivery: 318
X-DeMark-Signature: t=1790000000,v1=5f2c0b8e7a...

{"business": {"id": 7, "slug": "your-slug"}, "created": "2026-10-01T18:04:11.000Z",
 "created_at": "2026-10-01T15:04:11-03:00",
 "data": {"id": 981, "status": "confirmed", "start": "2026-10-02T14:30:00-03:00", "end": "2026-10-02T15:15:00-03:00",
          "service": {"id": 12, "name": "Haircut"}, "staff": null,
          "customer": {"name": "Marie Tremblay", "email": "marie.tremblay@mail.test", "phone": ""},
          "notes": "", "lang": "en", "source": "widget", "created_at": "2026-10-01T15:04:11-03:00",
          "manage_url": "https://demarkstudio.ca/citas/BOOKING_TOKEN"},
 "id": "evt_4412", "type": "booking.created"}

One real example per event

Each body below was generated by DeMark itself from a test booking, request, order and customer. data is the same model the REST API returns, so one class in your code reads both.

Version 1 only adds fields: ignore the ones you do not know. created is kept as a UTC alias of created_at for older receivers.

booking.created · data is a WebhookBooking
booking.created
{
  "business": {
    "id": 2,
    "slug": "salon-ana"
  },
  "created": "2026-10-03T02:24:52.567Z",
  "created_at": "2026-10-02T23:24:52-03:00",
  "data": {
    "created_at": "2026-10-02T23:24:52-03:00",
    "customer": {
      "email": "marie.tremblay@mail.test",
      "name": "Marie Tremblay",
      "phone": "506 555 0123"
    },
    "end": "2026-10-09T09:45:00-03:00",
    "id": 1,
    "lang": "fr",
    "location": {
      "id": 2,
      "name": "Salon Ana",
      "timezone": "America/Moncton"
    },
    "manage_url": "https://demarkstudio.ca/citas/c2-1-12f82226c4db44f694f2343dd1942d94",
    "notes": "First visit",
    "service": {
      "id": 3,
      "name": "Coupe"
    },
    "source": "api",
    "staff": {
      "id": 1,
      "name": "Julie"
    },
    "start": "2026-10-09T09:00:00-03:00",
    "status": "confirmed"
  },
  "id": "evt_2",
  "type": "booking.created"
}
booking.cancelled · data is a WebhookBooking
booking.cancelled
{
  "business": {
    "id": 2,
    "slug": "salon-ana"
  },
  "created": "2026-10-03T02:24:52.609Z",
  "created_at": "2026-10-02T23:24:52-03:00",
  "data": {
    "created_at": "2026-10-02T23:24:52-03:00",
    "customer": {
      "email": "luc.arsenault@mail.test",
      "name": "Luc Arsenault",
      "phone": ""
    },
    "end": "2026-10-09T10:45:00-03:00",
    "id": 2,
    "lang": "en",
    "location": {
      "id": 2,
      "name": "Salon Ana",
      "timezone": "America/Moncton"
    },
    "manage_url": "https://demarkstudio.ca/citas/c2-2-dcd2cbc1d9ca3db5593cd8364bf6349b",
    "notes": "",
    "service": {
      "id": 3,
      "name": "Haircut"
    },
    "source": "api",
    "staff": {
      "id": 1,
      "name": "Julie"
    },
    "start": "2026-10-09T10:00:00-03:00",
    "status": "cancelled"
  },
  "id": "evt_5",
  "type": "booking.cancelled"
}
request.created · data is a Request
request.created
{
  "business": {
    "id": 2,
    "slug": "salon-ana"
  },
  "created": "2026-10-03T02:24:52.624Z",
  "created_at": "2026-10-02T23:24:52-03:00",
  "data": {
    "attachments": [
      {
        "content_type": "image/png",
        "expires_at": "2026-10-05T23:24:52-03:00",
        "filename": "inspiration.png",
        "id": 1,
        "size": 69,
        "url": "https://demarkstudio.ca/adjuntos/2/1?exp=1791253492&sig=c376ff521d8e24efa9fa372d1b46bd445c99acfc839ccd051a54d04c9dff4c3f"
      }
    ],
    "created_at": "2026-10-02T23:24:52-03:00",
    "customer": {
      "email": "marc.leblanc@mail.test",
      "name": "Marc Leblanc",
      "phone": "506 555 0199"
    },
    "id": 1,
    "lang": "fr",
    "message": "Hair and make-up for six people on a Saturday morning.",
    "page_url": "https://www.your-salon.ca/weddings",
    "source": "api",
    "status": "new",
    "subject": "Wedding party, 6 people",
    "type": "quote"
  },
  "id": "evt_7",
  "type": "request.created"
}
order.paid · data is a Order
order.paid
{
  "business": {
    "id": 2,
    "slug": "salon-ana"
  },
  "created": "2026-10-03T02:24:52.652Z",
  "created_at": "2026-10-02T23:24:52-03:00",
  "data": {
    "created_at": "2026-10-02T23:24:52-03:00",
    "currency": "CAD",
    "customer": {
      "email": "lea.roy@mail.test",
      "name": "Lea Roy",
      "phone": ""
    },
    "delivery": "pickup",
    "id": 1,
    "items": [
      {
        "name": "Cedar candle",
        "price": "20.00",
        "product_id": 1,
        "qty": 2
      }
    ],
    "number": "O-2026-0001",
    "shipping": "0.00",
    "status": "paid",
    "subtotal": "40.00",
    "tax": "0.00",
    "total": "40.00"
  },
  "id": "evt_10",
  "type": "order.paid"
}
customer.created · data is a Customer
customer.created
{
  "business": {
    "id": 2,
    "slug": "salon-ana"
  },
  "created": "2026-10-03T02:24:52.552Z",
  "created_at": "2026-10-02T23:24:52-03:00",
  "data": {
    "company": "",
    "created_at": "2026-10-02T23:24:52-03:00",
    "email": "marie.tremblay@mail.test",
    "id": 2,
    "lang": "fr",
    "marketing_consent": false,
    "name": "Marie Tremblay",
    "notes": "Prefers mornings",
    "phone": "506 555 0123"
  },
  "id": "evt_1",
  "type": "customer.created"
}
order.created · data is a Order
order.created
{
  "business": {
    "id": 2,
    "slug": "salon-ana"
  },
  "created": "2026-10-03T02:24:52.645Z",
  "created_at": "2026-10-02T23:24:52-03:00",
  "data": {
    "created_at": "2026-10-02T23:24:52-03:00",
    "currency": "CAD",
    "customer": {
      "email": "lea.roy@mail.test",
      "name": "Lea Roy",
      "phone": ""
    },
    "delivery": "pickup",
    "id": 1,
    "items": [
      {
        "name": "Cedar candle",
        "price": "20.00",
        "product_id": 1,
        "qty": 2
      }
    ],
    "number": "O-2026-0001",
    "shipping": "0.00",
    "status": "pending_payment",
    "subtotal": "40.00",
    "tax": "0.00",
    "total": "40.00"
  },
  "id": "evt_9",
  "type": "order.created"
}
invoice.paid · data is a WebhookInvoice
invoice.paid
{
  "business": {
    "id": 2,
    "slug": "salon-ana"
  },
  "created": "2026-10-03T02:24:52.731Z",
  "created_at": "2026-10-02T23:24:52-03:00",
  "data": {
    "base": "510.00",
    "created_at": "2026-10-02T23:24:52-03:00",
    "currency": "CAD",
    "customer": {
      "email": "marie.tremblay@mail.test",
      "name": "Marie Tremblay",
      "phone": null
    },
    "due_date": "2026-11-01",
    "id": 1,
    "lines": [
      {
        "amount": "510.00",
        "concept": "Wedding party",
        "detail": "Hair and make-up, 6 people",
        "qty": 6.0,
        "tax_exempt": false,
        "unit_price": "85.00"
      }
    ],
    "number": "2026-0001",
    "paid_at": "2026-10-02T23:24:52-03:00",
    "status": "paid",
    "tax": "76.37",
    "taxes": [
      {
        "amount": "25.50",
        "name": "GST",
        "rate": 5.0,
        "registration_number": "123456789 RT0001"
      },
      {
        "amount": "50.87",
        "name": "QST",
        "rate": 9.975,
        "registration_number": "1234567890 TQ0001"
      }
    ],
    "total": "586.37"
  },
  "id": "evt_11",
  "type": "invoice.paid"
}

Verify the signature

X-DeMark-Signature holds t, a Unix timestamp, and v1, an HMAC-SHA256 of t, a dot and the raw body, keyed with your webhook secret (whsec_...). Compute it on the raw bytes, compare in constant time, and reject anything more than 5 minutes old.

Python
Python
import hashlib
import hmac
import time


def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    try:
        timestamp = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - timestamp) > tolerance:
        return False
    signed = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

# With Flask, for example:
# verify(os.environ["DEMARK_WEBHOOK_SECRET"], request.headers["X-DeMark-Signature"], request.get_data())
Node
Node (Express)
const crypto = require("crypto");
const express = require("express");

function verify(secret, header, rawBody, tolerance = 300) {
  const parts = Object.fromEntries((header || "").split(",").map((p) => p.split("=", 2)));
  const timestamp = Number(parts.t);
  if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > tolerance) return false;
  const expected = Buffer.from(
    crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex"));
  const received = Buffer.from(parts.v1 || "");
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();
// Verify the raw body, before any JSON parsing
app.post("/webhooks/demark", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(process.env.DEMARK_WEBHOOK_SECRET, req.get("X-DeMark-Signature"), req.body)) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(req.body);
  res.sendStatus(204); // answer fast, do the slow work afterwards
});
PHP
PHP
<?php
function demark_verify(string $secret, string $header, string $rawBody, int $tolerance = 300): bool
{
    $parts = [];
    foreach (explode(',', $header) as $part) {
        [$name, $value] = array_pad(explode('=', $part, 2), 2, '');
        $parts[$name] = $value;
    }
    $timestamp = (int) ($parts['t'] ?? 0);
    if ($timestamp === 0 || abs(time() - $timestamp) > $tolerance) {
        return false;
    }
    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    return hash_equals($expected, $parts['v1'] ?? '');
}

$rawBody = file_get_contents('php://input');
if (!demark_verify(getenv('DEMARK_WEBHOOK_SECRET'), $_SERVER['HTTP_X_DEMARK_SIGNATURE'] ?? '', $rawBody)) {
    http_response_code(400);
    exit;
}
$event = json_decode($rawBody, true);
http_response_code(204);

Delivery and retries

  • Answer with any 2xx status within 10 seconds. Redirects are not followed.
  • If your server fails or is too slow, we try again after 1 min, 5 min, 30 min, 2 h, 12 h. After the last attempt the delivery is marked as failed in the history, where you can send it again.
  • A retry or a resend keeps the same event id (evt_...) while X-DeMark-Delivery changes. Keep the ids you have processed and ignore repeats.
  • The secret is shown once, when you create the endpoint. Keep it in an environment variable, never in your code.
  • The Send test button sends a ping event, so you can check your endpoint before real events arrive.

08 / 11

Security

There are two kinds of keys, because a web page is public and your server is not.

dmk_pk_ Public key

Goes in your web pages. It only works from the domains you allowed and can only read public information and create bookings, requests, carts, registrations and chat messages. Someone who copies it can do nothing your booking page doesn't already allow.

dmk_live_ Secret key

Stays on your server, in an environment variable. It can read customers, bookings and orders: whoever holds it can act for your business within its permissions.

  • Allowed domains are exact hostnames: add both www.your-salon.ca and your-salon.ca if your site answers on both.
  • Never put a secret key in a web page, a mobile app or a public repository. The API refuses a secret key sent from a browser, so the mistake shows up while testing rather than in production.
  • Give each key only the permissions it needs, and use one key per integration: you can then revoke one without breaking the others.
  • To rotate a key, create the new one, deploy it, check the call log, then revoke the old one; revoking takes effect immediately. Essential allows a single key, so there you revoke first and paste the new key right away.
  • Settings › Developers keeps a log of recent calls for each key (time, endpoint, status), so you can see what still uses a key before revoking it.
  • Webhook addresses must use HTTPS. Verify the signature before trusting anything in a webhook body.

09 / 11

Troubleshooting, errors and limits

When a widget stays empty or a call fails, the cause is almost always one of five things. Check them in this order, then look up the error.

When something does not work

  1. Domain and origin

    Open the page, press F12, go to Network and find the call to /api/v1/me. origin_not_allowed means the hostname in the address bar is not in your allowed domains: check www or not, staging, wixsite.com or myshopify.com. An iframe that stays blank with Refused to frame in the console is on a page that is not https, or not on a listed hostname.

  2. Key and permissions

    missing_key: data-key is empty or missing. invalid_key: a typo, or the key was revoked. missing_permission: the public key was created without that widget's permission (booking reservas, request solicitudes, chat chat, events eventos, shop tienda); create one that has it.

  3. Tool turned off, or just empty

    A widget whose tool is off shows its link or Not available rather than an error. Ask the API: 404 shop_disabled or chat_disabled means turned off in Settings › Tools; 200 with data: [] means turned on with nothing to show yet (no active product, no upcoming event date, no free time).

  4. Language

    Put data-lang=fr on the script or the element, and check that French is one of the business languages in Settings › Business and that the French names are filled in. Then call GET /services?lang=fr: if the API answers in French and the widget does not, the attribute is not reaching it (a cached copy of the page, or a second script with data-lang=en).

  5. Where to see the failed call

    Settings › Developers keeps the last 100 API calls with their time, method, path, status and origin. Settings › Developers › Webhooks keeps every delivery with its event, attempt, HTTP code, error and next retry, and a Resend button. Emails shows the queued confirmations.

Turned off or empty: the same call, two answers
GET https://demarkstudio.ca/api/v1/products?lang=en
404 {"error": {"code": "not_found", "message": "shop_disabled"}}   <- Online shop turned off
200 {"data": [], "next_cursor": null}                              <- turned on, no active product yet

Every error, and what to do

Errors always have the same shape. Branch on message, which is stable; code groups them by HTTP status; fields points at the inputs to fix.

422 Unprocessable Entity
{"error": {"code": "validation", "message": "invalid_fields",
           "fields": {"attachments.0.content_type": "unsupported_type"}}}
Error answers of API v1
HTTPcodemessageWhat it means and what to do
401unauthorizedmissing_keyNo Authorization: Bearer header, or data-key is missing. Send the key.
401unauthorizedinvalid_keyWrong or revoked key, or the business is no longer active. Copy the key again from Settings › Developers.
403forbiddenorigin_not_allowedThe page's hostname is not in the allowed domains. Add it exactly.
403forbiddensecret_key_requiredA public key on a resource for servers (customers, lists, orders). Call it from your server with the secret key.
403forbiddensecret_key_in_browserA secret key sent from a browser. Move that call to your server and use the public key in pages.
403forbiddenmissing_permissionThe key lacks the permission for that resource. Create a key that has it.
403forbiddenplan_requiredThe plan does not include that function, for example webhooks on Essential.
403forbiddenservice_unavailableThe DeMark membership is not active. Check Settings › Membership.
403forbiddenreturning_clients_onlyThe business only takes online bookings from returning clients. Tell the person to contact the business.
403forbiddentoo_many_future_bookingsThis email already has the most upcoming bookings the business allows online. Tell the person to contact the business.
404not_foundservice_not_foundNo such service in this business. Take the ids from GET /services.
404not_foundbooking_not_foundWrong booking id, an id from another business, or a token that belongs to another booking.
404not_foundcustomer_not_foundNo such customer in this business.
404not_foundorder_not_foundNo such order in this business.
404not_foundevent_not_foundNo such event, or it is no longer active.
404not_foundlocation_not_foundNo such active location in this business. Take the ids from GET /locations.
404not_foundshop_disabledOnline shop is off in Settings › Tools. Turned on with no product, the list answers data: [] instead.
404not_foundchat_disabledThe chat assistant is turned off.
409conflictslot_unavailableSomeone took that time. Read the availability again and offer another time.
409conflictcancellation_windowToo close to the appointment to cancel online: the customer contacts the business.
409conflictbooking_not_activeCompleted and no-show bookings cannot be cancelled.
409conflictstaff_not_at_locationThe staff member on this booking does not work at that location. Pick another location or another staff member.
409conflictroom_not_at_locationThe service needs a room or equipment that location does not have. Pick another location.
422validationinvalid_requestThe JSON shape or a type is wrong; fields names the path.
422validationinvalid_fieldsA value was refused; fields gives the reason for each field (list below).
429rate_limitedrate_limitedToo many calls. Wait: when the key limit is reached, Retry-After gives the seconds (see Limits).
500internalinternal_errorOur fault. Try again later, and tell us if it keeps happening.

Reasons you can find in fields: required, invalid_email, invalid_date, invalid_datetime, invalid_choice, invalid_cursor, invalid_occurrence, invalid_session, unsupported_type, too_large, empty, invalid_base64, too_many, too_many_items, invalid

Limits

  • 600 requests per minute per key, counted as a bucket rather than a fixed minute. The bucket holds 600 and refills at 10 per second: a burst of 600 calls goes through at once, then about 10 per second, and a second burst soon after is refused sooner because the bucket has not refilled. Every answer carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (the Unix time when the bucket is full again). A 429 carries Retry-After: 60, a safe upper bound.
  • With a public key, writes are also limited per visitor IP: bookings 10 an hour, requests 10 an hour, event registrations 20 an hour, carts 120 per 10 minutes, chat 30 turns per 10 minutes, and cancelling with only the booking token 10 an hour. Those 429 answers carry no Retry-After: the visitor waits, and your code should not loop.
  • Sizes: message up to 4096 characters; files up to 5 MB each once decoded, 3 per request; list limit from 1 to 100, 25 by default.

Retries and lost answers

  • Reads (GET) are safe to retry: retry network errors, 429 and 5xx with a growing wait (1, 2, 4 seconds and so on, up to 60). Never retry other 4xx answers.
  • API v1 has no Idempotency-Key. If the answer to POST /bookings or POST /requests is lost (timeout, dropped connection), do not send it again blindly: first look for what may already exist.
  • Booking, with the secret key: list GET /bookings?from=DAY&to=NEXT_DAY and look for the same customer email, service id and start. Found: use it. Not found: create it again. A 409 slot_unavailable right after a lost answer usually means your first call worked: look again.
  • Request, with the secret key: GET /requests lists the newest first; look for the same customer email with a created_at from the last few minutes.
  • In the browser, with a public key, there are no lists: ask the visitor to check for the confirmation email before trying again. The owner sees everything in Appointments and Requests.
  • Safe to repeat: POST /customers (the same email answers 200 with created: false) and DELETE on a booking (an already cancelled booking answers 200).
Find a booking after a lost answer (Python, secret key)
# all_bookings is the paging loop below.
def find_booking(email, service_id, start, day, next_day):
    for booking in all_bookings(day, next_day):
        if (booking["customer"]["email"].lower() == email.lower()
                and (booking["service"] or {}).get("id") == service_id and booking["start"] == start):
            return booking
    return None

Compatibility of v1

  • v1 only adds: new fields in answers and webhooks, new optional fields in bodies, new endpoints. Ignore the fields you do not know. Nothing is removed or renamed without a new version (/api/v2 and new event names).
  • A deprecated field stays for all of v1, for example created in webhooks: use created_at.
  • New webhook event types are never sent to an existing endpoint on their own: tick them for that endpoint in Settings › Developers › Webhooks.
  • Fields documented with an open list, such as source, can gain values; the status values listed in the models are the complete set for v1.

Paging through a whole list

Lists answer data and next_cursor, newest first. Pass next_cursor back as cursor, with the same filters, until it is null. The cursor is opaque. A cursor that no longer matches (an event date that was removed, for example) answers 422 with invalid_cursor in fields: start again from the first page.

Paging through a list
# demark() is the helper from the Python example.
API = "https://demarkstudio.ca/api/v1"


def all_bookings(day_from, day_to):
    cursor = None
    while True:
        path = f"/bookings?from={day_from}&to={day_to}&limit=100"
        if cursor:
            path += f"&cursor={cursor}"
        page = demark("GET", path)
        yield from page["data"]
        cursor = page["next_cursor"]
        if not cursor:
            break

10 / 11

Test the complete journey

One salon, in English and in French, from the first click to the webhook. Use the demo business or your own on a staging page, and tick each expected result before you hand the site over.

Set up

Everything in Before you start, plus: services with English and French names (Haircut and Coupe), French among the business languages, a public key with reservas and solicitudes for your staging hostname, a secret key with citas and solicitudes on your server and, on Complete, a webhook endpoint subscribed to booking.created, booking.cancelled, request.created and customer.created.

Two pages, one per language

The same widgets on both pages; only lang and data-lang change. Keep them side by side for the whole test.

English page
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Book online</title></head>
<body>
  <div data-demark="booking"><a href="https://demarkstudio.ca/salon-ana/book?lang=en">Book online</a></div>
  <div data-demark="request" data-type="quote"></div>
  <script src="https://demarkstudio.ca/embed/booking.js" data-key="dmk_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" data-lang="en" defer></script>
  <script src="https://demarkstudio.ca/embed/request.js" data-key="dmk_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" data-lang="en" defer></script>
</body>
</html>
French page
<!doctype html>
<html lang="fr">
<head><meta charset="utf-8"><title>Réserver en ligne</title></head>
<body>
  <div data-demark="booking"><a href="https://demarkstudio.ca/salon-ana/book?lang=fr">Réserver en ligne</a></div>
  <div data-demark="request" data-type="quote"></div>
  <script src="https://demarkstudio.ca/embed/booking.js" data-key="dmk_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" data-lang="fr" defer></script>
  <script src="https://demarkstudio.ca/embed/request.js" data-key="dmk_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" data-lang="fr" defer></script>
</body>
</html>

Steps and expected results

The complete journey, in English and French
#DoExpect
1Open both pages in a private window.The English page lists Haircut; the French page lists Coupe, with French buttons.
2Book a time on the French page.A confirmation in French naming Coupe; the booking in Appointments; a French confirmation in Emails; booking.created with data.lang fr, data.service.name Coupe and data.staff as an object.
3Press Annuler la réservation in the widget.A DeMark page (citas/TOKEN/cancel?lang=fr) shows the booking and a confirm button. Once confirmed, the booking is cancelled and booking.cancelled arrives with the same data.id. Inside the cancellation window the page says so and shows no button.
4On the English page, send a quote request with a PNG or PDF file.Requests shows it with the file, which the owner opens or downloads; request.created has attachments with a url valid for 72 hours, and message holds only what the customer wrote.
5Read the catalog in French from a terminal (commands below).GET /services?lang=fr answers Coupe; the links in /events?lang=fr and /products?lang=fr keep lang=fr.
6Compare each webhook with the REST answer.The data of booking.created equals GET /bookings/ID plus manage_url; request.created equals the request in GET /requests.
7Open the page on a hostname that is not listed, then revoke the public key.403 origin_not_allowed, then 401 invalid_key: the widgets show their fallback link.
Steps 5 and 6 from a terminal (secret key)
API=https://demarkstudio.ca/api/v1
KEY=dmk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

# 5. Catalog in French: names in French, links keep lang=fr
curl -s "$API/services?lang=fr" -H "Authorization: Bearer $KEY"
curl -s "$API/events?lang=fr" -H "Authorization: Bearer $KEY"
curl -s "$API/products?lang=fr" -H "Authorization: Bearer $KEY"

# 6. REST objects to compare with the webhooks
curl -s "$API/bookings?from=2026-10-01&to=2026-10-31" -H "Authorization: Bearer $KEY"
curl -s "$API/bookings/981" -H "Authorization: Bearer $KEY"
curl -s "$API/requests?limit=5" -H "Authorization: Bearer $KEY"

This journey is tested on a plain HTML page. Before promising it on a WordPress, Wix, Squarespace or Shopify site, run the same steps on that site's staging copy, with its real theme, cache and plugins.

11 / 11

Questions

Which plan do I need?
Both plans can connect a website. Essential includes one key and all five widgets. Complete adds unlimited keys, handy with several sites or integrations, and webhooks.
My site is on the free Wix plan. Does it work?
Yes: use the iframe version in an Embed HTML box, which the free plan allows. If the box stays blank, a plain button linking to your DeMark booking page always works. The script version needs a Premium plan with your own domain.
What about privacy law: PIPEDA, Quebec's Law 25 and the GDPR?
The widgets use the same forms and consent wording as your DeMark booking page. With the API, you collect consent on your own form and send consent: true; creating a customer without it is refused. Your data is hosted in Canada. Name DeMark as a service provider in your privacy policy, as Law 25 expects.
Can the widgets be in French and in English?
Yes. Set data-lang to fr or en on each widget: it translates the buttons and also reaches every catalog read and link, so service, event and product names show in that language. Use the same snippet on your French and English pages, each with its own data-lang. Names, emails and reminders use the texts you entered in each language in DeMark.
How do customers cancel a booking made on my website?
The widget's Cancel booking button and the link in the confirmation email open a DeMark page where the customer sees the booking and confirms the cancellation; your cancellation policy applies. With your own form, the API returns manage_url and cancel_url (that same page) when the booking is created, and your server can cancel with a DELETE on the booking and its token.
Will the widget look like my site?
It uses your DeMark brand colour by default. To match your site exactly, set the --dmk- CSS variables (accent, text, background, radius and font) in your own stylesheet.
What do visitors without JavaScript see?
Put a normal link to your booking page inside the widget's element. Visitors with JavaScript get the widget in its place; the others still have a working link.
Can I use DeMark on several websites?
Yes. Add every domain in Settings › Developers. On Complete you can give each site its own key, and follow and revoke each one separately.
I published my secret key by mistake. What now?
Revoke it in Settings › Developers right away: it stops working immediately. Then create a new one, put it on your server, and look through the call log for anything you don't recognise. A public key in a web page is not a leak; that is where it belongs.
What happens above 600 requests per minute?
The API answers 429 with a Retry-After header. The limit is a bucket that refills at 10 requests per second, not a counter that resets each minute: after a burst you get about 10 per second again. A widget makes a handful of requests per visitor, so only scripts that loop over the API come close. Cache your services list instead of fetching it on every page view.
Can customers send files with a request?
Yes, up to 3 per request: PDF, JPEG, PNG, WebP or plain text, 5 MB each. The owner opens or downloads them from the request in Requests. Integrations get a signed link for each file in GET /requests and in the request.created webhook; it works for 72 hours.
On WordPress the widget doesn't appear, or an old version shows. Why?
View the source of the published page and look for data-demark and /embed/. If they are missing, an optimisation plugin removed, combined or delayed the script: exclude the /embed/ scripts from those options and purge the cache. Caching the page itself is fine, because the widgets load their data live.
A visitor pressed Back after sending a form. What happens?
The browser may show the filled-in form again. Sending it again creates a second request, or, for a booking already made, answers that the time is no longer available. The confirmation email is the visitor's reference, and the owner can close a duplicate in Requests or cancel it in Appointments.
What happens when I revoke a key?
It stops working at once. Widgets using it show their fallback link and the API answers 401 invalid_key. Create the new key, paste it everywhere the old one was, and check the call log in Settings › Developers.
My server lost the answer to a booking request. Can I send it again?
Not blindly: API v1 has no Idempotency-Key. First look with your secret key in GET /bookings for that day (or GET /requests), matching the customer's email and time, and only create it again if it is not there. Troubleshooting shows the steps.

Your site stays. Your back office gets better.

Start the trial, create a public key and paste the snippet. If something doesn't work on your platform, write to us and we'll look at it with you.

Start free Ask a question

Cookies

We use one cookie to see how you found us (an email, a search or an ad) and count the sign-ups each one brings. If you accept, it also keeps an ad click ID for 90 days; if you decline, only the campaign name. Privacy policy