# Read the workspace audit log
Source: https://docs.dmly.io/account/audit-log
Browse an append-only trail of high-value actions taken in your workspace, filterable by action and date range.
The audit log is a record of *who did what, when* for a specific set of high-value or destructive actions in your workspace, not a complete history of everything that happens. Each row is written once and never edited. This page is an honest account of what it currently covers, so you know what to trust it for and what to still check elsewhere.
Viewing the audit log needs the **View the audit log** permission (`team.audit_log`). The workspace owner and Admins hold it by default; Members and Viewers don't, though a custom role can grant it to a Member. See [Roles and permissions](/account/roles-and-permissions).
## Open the audit log
Workspace Settings is a separate area from the main sidebar, with its own nav: **Settings**, **Team Members**, **Billing**, **Receipts**, and **Security**.
**Audit log** is a tab on the **Settings** page, alongside **Business Details**, **Conversation settings**, **Appointment settings**, **Finance settings**, **Notification settings**, **AI Knowledge** and **Custom fields**.
## What's on the page
Each row shows **When**, **Who** (the team member's name, or **System** if no one was signed in when it happened), **Action** (a short code like `contact.delete`), **Summary** (a plain-language sentence of what happened), and **IP** (the address the request came from). The list is paginated, 30 rows at a time.
Selecting a row doesn't open any further detail; the **Summary** column is the full description you get. There's no export or delete control on the page; it's read-only.
## Filtering
A dropdown of action codes. It's built from the actions that have actually happened in your workspace, so a quiet workspace will show a short list.
**All time**, **Last 24 hours**, **Last 7 days**, or **Last 30 days**.
## What's actually logged today
This is the part worth reading carefully: the audit log covers three main groups of actions, plus a short list of other money and data actions, not every change made in the workspace.
### Actions taken in the dashboard
| Action code | What it records |
| - | - |
| `contact.delete` | Deleting a single contact |
| `contact.bulk_delete` | Bulk-deleting contacts |
| `contact.bulk_add_tag` | Bulk-adding a tag to contacts |
| `contact.bulk_remove_tag` | Bulk-removing a tag from contacts |
| `contact.bulk_set_stage` | Bulk-changing contacts' lifecycle stage |
| `offering.services_import` | Importing services from a CSV file |
| `automation.bulk_delete` | Bulk-deleting automations |
These rows come from the dashboard only. The same actions performed through the [REST API](/api-reference/introduction) write no audit row at all, so a contact deleted by a script or an integration leaves nothing here.
### An agency opening your workspace
| Action code | What it records |
| - | - |
| `agency.impersonate` | A whitelabel agency signing in to your workspace as its owner, with **Open** |
This one only appears in a workspace an agency manages. See [Client workspaces](/agency/client-workspaces).
The row is written at the moment the agency signs in, and it is the only marker you get. From then on they are acting *as* the owner, so anything else they do while inside is recorded under the owner's name, not theirs. The **Who** on the `agency.impersonate` row itself is the agency's own user, which may be a name you don't otherwise see in your team list.
### Writes made by an AI client over MCP
| Action code | What it records |
| - | - |
| `mcp.session_started` | An AI client connecting to the workspace, naming the client that connected |
| `mcp.` | One row per write that client makes, for example `mcp.send_message`, `mcp.create_invoice` or `mcp.book_appointment` |
Each of the 20 tools that change something writes its own row, so an AI booking an appointment, tagging a contact or queueing a broadcast all leave a trail. Reads don't: a model looking up a contact or reading a conversation writes nothing, on purpose, because a read tool called several times per reply would bury everything else. Nor does the broadcast preview step, which only works out who a broadcast would reach; the `mcp.submit_broadcast` row is what records the send being queued for approval.
What the **Who** column shows depends on how the client connected. Through a connection someone authorized with their own login, it's that person's name. Through a workspace API key there's nobody signed in, so it reads **System**. The `mcp.` prefix on the action code is the reliable signal that an AI did it, because the **Summary** doesn't always say so. See [the MCP server](/mcp/overview).
### Other actions that are logged
A few more actions also write a row:
| Action code | What it records |
| - | - |
| `contact.restored` | A deleted contact brought back |
| `contact.recovery_purged` | The recovery data for a deleted contact permanently erased |
| `finance.bank_account_updated` | The bank account for manual transfers changed |
| `payment.manual_confirmed` | A bank transfer confirmed |
| `payment.manual_rejected` | A bank transfer rejected |
| `payment.refund_reversed` | A refund reversed |
| `calendar.staff_disconnected` | A staff member's Google Calendar disconnected |
| `livechat.revoked` | A Live Chat widget retired (its conversations kept) |
| `livechat.purged` | A Live Chat widget's data erased |
| `sequence.subscriber_removed` | A contact removed from a sequence |
| `review_request.attribution` | A review request's attribution corrected |
| `subscription.paused_unpaid` | A client subscription paused for non-payment (system) |
| `subscription.resumed_new_card` | A paused subscription resumed after the client saved a new card (system) |
Nothing outside the lists on this page writes to the audit log. Deleting a single automation, editing a broadcast, refunding a payment (only reversing a refund is logged), removing a team member, and most other admin actions don't appear here at all. Don't rely on this page to answer "who changed X" for anything outside the lists above. For message and delivery failures, see [Logs](/troubleshooting/logs) instead, which is a different, unrelated record.
## Retention
Audit log entries are kept for **180 days**, then removed automatically. There's no way to extend that from inside DMLY, and no export button, so if you need to keep a record longer than that, copy what you need out before it ages off.
You need the **View the audit log** permission. The workspace owner and Admins have it automatically; a Member needs a custom role that grants it. See [Roles and permissions](/account/roles-and-permissions).
Most actions in DMLY don't write an audit row; only the ones listed above do. This isn't a bug; that action simply isn't instrumented.
Entries older than 180 days are pruned automatically and can't be recovered.
## Next
Grant the audit log permission to a custom role.
Invite people and set their base role.
Two-factor authentication and other account protections.
The separate, unrelated record of message and automation delivery events.
# Set your business details
Source: https://docs.dmly.io/account/business-details
Set the business name, logo, brand colour, currency and contact details your workspace uses everywhere.
Your workspace name **is** your business name; there is no separate field for the two. Change it here and it changes everywhere DMLY refers to your business. The rest of this page is the supporting detail: your logo, brand colour, currency, and the registration and contact details a business needs on record.
Only workspace **Admins** can open Workspace Settings. Members and Viewers cannot reach it at all. See [Roles and permissions](/account/roles-and-permissions).
## Open Business Details
Workspace Settings is a separate area from the main sidebar. It opens with its own nav (**Settings**, **Team Members**, **Billing**, **Receipts**, and **Security**) and a **Back** button to return to the app.
**Business Details** is the first tab on the **Settings** page.
Everything on the tab is one form. One save writes the lot.
## What lives here
The name of your workspace and the name of your business. One field, one source of truth.
Upload your business logo.
Your brand colour.
Pick from the **Select currency** list. DMLY offers 30 currencies.
**Business Email** and **Business Phone**.
**Company Registration Number** and **Tax ID**.
**Business Address** is six fields: **Address Line 1**, **Address Line 2**, **City**, **State / Region**, **Postal Code**, and **Country**.
Set the business name carefully at the start. It is the name your customers see, and automation and AI replies use it too. See [AI replies](/automation/ai-replies).
## Timezone
Timezone isn't on this tab. There are two of them, in two different places:
* **Your own display timezone** is a personal preference, so each person on the team can work in their own. Set it under **Edit Profile → My Preferences**. See [Your profile](/account/profile).
* **The timezone your public booking page shows times in** is a business setting, on the **Appointment settings** tab. Its **Timezone** section has a **Timezone mode** choice (**Detect automatically** or **Fixed timezone**), and picking **Fixed timezone** reveals a picker for which one. See [Appointments](/appointments/overview).
## The other Settings tabs
Business Details sits alongside seven more tabs on the same **Settings** page:
Human takeover, conversation ownership, reopening, and the AI reply policy.
Booking rules and availability.
Invoicing, tax and payment behaviour.
Which events notify clients and staff, on which channel.
Extra fields on your contact records.
Who did what in the workspace.
The one without a card is **AI Knowledge** (the documents your AI answers from). There is no
**Tags** tab here: tags are managed with **Manage tags** on the
[Contacts](/contacts/overview) page.
Deleting a workspace deletes all its data and cannot be undone. It lives on **Workspace Settings → Security**, is available to the workspace owner only, and asks you to type the workspace name to confirm. See [Security](/account/security).
# Set how conversations behave
Source: https://docs.dmly.io/account/conversation-settings
Choose what a teammate's reply does to the bot, who owns a conversation afterwards, when a closed one reopens, and whether the AI may answer.
**Conversation settings** governs the handover between your team and your automations: whether replying takes a conversation off the bot, when the bot gets it back, who owns it, and whether the AI is allowed to answer at all. Every control on the tab already matches how DMLY behaves out of the box, and the tab says so in a banner at the top: **These already match how DMLY behaves out of the box — change them only if you want something different.** So this is somewhere you come to change your mind, not a setup step you have to complete.
## Open the tab
Select your name and avatar at the bottom of the sidebar, then **Settings**.
It is the second tab, after **Business Details**.
One **Save** button at the top of the page saves the whole tab. Nothing applies until you select it.
Two things about the scope of this tab:
* **It is workspace-wide.** There is no channel picker here. What you set applies to WhatsApp, Messenger, Instagram, Telegram, Live Chat, SMS and every other connected Channel, and to every teammate.
* **It is admin territory.** Reaching the tab needs a permission in the **Team & Settings** module, and saving needs **Manage workspace settings**. Admins and the workspace owner hold both; Members and Viewers hold neither by default, though a custom role can grant them. Note that the **Settings** entry in the sidebar menu is only shown to Admins, so a teammate on a custom role reaches the tab from the tab strip on another Settings tab rather than from the menu. See [Roles and permissions](/account/roles-and-permissions).
## Human takeover
### When a teammate replies
* **Pause the bot and the running flow** (the default)
* **Change nothing — keep the bot running**
The tab's own explanation: **Replying is usually a takeover: the automation stops where it is, keeps its place, and stops answering for you.**
On the default, sending a reply does two things at once. It pauses the bot for that contact (the same state as **Pause bot (human takeover)** in the details panel), and it *parks* any automation the contact was part-way through: the flow keeps its current step, the answers already given, and whatever was left of a wait timer, rather than being cancelled or restarted. A parked flow shows as a banner in the details panel with **Resume**, **Skip this step** and **End automation**. See [Work a conversation as a team](/inbox/notes-and-collaboration).
Two precise points that save confusion:
* **The takeover only happens if the reply actually goes out.** A reply DMLY refuses to send (the WhatsApp 24-hour window has closed, the message body is empty, a template has a blank value) leaves the bot running and nothing parked.
* **It applies to a person's reply.** A message sent by an automation, a notification or a broadcast is not a takeover.
Choose **Change nothing — keep the bot running** and replying is just a message: no pause, no parked flow. Your automations and your team can then answer the same conversation, so use it when the bot handles one narrow job (an order lookup, a booking link) that a human reply should not interrupt. Ownership is a separate setting and still applies.
### When should automation resume after a human reply?
* **Only when someone resumes it** (the default)
* **When the conversation is closed**
* **After a quiet period**, which reveals an hours box: anything from 1 to 720 hours (30 days), and 24 if you leave it alone
* **Never — end it by hand**
The tab's own explanation: **A paused flow keeps its place. Closing a conversation doesn't prove the automation is still wanted, so by default it waits for you. Flows holding a payment, invoice, booking or order are never resumed automatically.**
What each one does:
* **Only when someone resumes it**: nothing hands back on its own. Somebody selects **Resume bot**, or the banner's **Resume** or **Skip this step**. A flow's **Resume bot replies** step lifts the pause too, though it leaves the parked run itself frozen.
* **When the conversation is closed**: closing hands the conversation back. In the web Inbox you are still asked (the prompt below), and this setting only pre-selects **Resume it from the same step**. Where nothing can ask, the hand-back happens by itself: a flow's **Close conversation** step, the [mobile app](/getting-started/mobile-app), or an AI assistant closing a chat over [MCP](/mcp/overview).
* **After a quiet period**: DMLY checks every minute and hands the conversation back once the hours you set have passed. The clock runs from when the takeover happened, not from the last message, so a long conversation does not keep pushing it out. This one only lifts a pause that came from a takeover (a reply, or **Pause bot (human takeover)**); a pause set by a flow's **Handover to Human** step, or by blocking a contact, is never swept.
* **Never — end it by hand**: a parked flow only ever moves because a person moved it. The close prompt drops **Resume it from the same step** entirely, and a close arriving from a flow step, the mobile app or MCP leaves the flow parked as well.
This select is greyed out when the setting above is **Change nothing — keep the bot running**, with the line **Nothing to resume while replies don’t pause the bot.** Your stored choice is kept, and it still applies to a takeover you start by hand with **Pause bot (human takeover)**, which parks a running flow the same way a reply does.
**An automatic hand-back never resumes a flow that moves money or commits a slot.** If the parked flow contains a payment, invoice, order, subscription, coupon, credit, booking, confirmation, cancellation, reschedule, class enrolment, discount, a sent product/service/plan, or a webhook, DMLY leaves it parked and leaves the bot paused, even when your policy says resume. The agent may have taken that payment or booking by hand, and replaying it is worse than waiting. A person resuming it themselves does resume it: that is a decision, not a sweep.
## Ownership
### Who owns the conversation after a reply
* **Whoever replied last** (the default)
* **Only claim it if nobody owns it**
* **Replying never changes the owner**
The tab's own explanation: **By default it follows whoever answered last — which also takes it off a teammate the moment somebody else replies.**
This is the setting behind the behaviour people notice first in a shared Inbox: a teammate answers a chat that was yours, and it quietly becomes theirs. **Only claim it if nobody owns it** keeps the reply-claim for unassigned conversations but never takes one off a teammate. **Replying never changes the owner** leaves assignment entirely to hand and to your automations.
A claim from replying is silent either way: no bell for the new owner, no bell for the old one, and no `conversation.assigned` webhook. See [Assign chats to teammates](/inbox/assigning-chats).
### Give new conversations an owner
* **Leave them unassigned** (the default)
* **Round-robin across the team**
The tab's own explanation: **Round-robin picks whoever currently has the fewest open conversations. Everyone in the workspace counts as an agent, so leave this off unless that is true for your team.**
Round-robin fires once, on a contact's first ever inbound message, and never on a returning contact. It compares how many **Open** conversations each workspace member holds right now (not how many they have ever been given) and hands the new one to the lowest; ties break the same way every time, so the result is stable rather than random. It never takes a conversation that already has an owner, and it assigns before the new-conversation alert goes out, so the alert names the right person. The assignee gets a bell notification; no `conversation.assigned` webhook is sent.
**There is no agent list.** Every user in the workspace is a candidate, including your bookkeeper and anyone who only logs in to read reports. There is no on-call or availability filter either: **On call duty** in the sidebar is for [WhatsApp calls](/inbox/whatsapp-calling) and has no effect here. If your workspace is not all-hands-on-the-Inbox, leave this off and triage the **Unassigned** filter by hand.
## Reopening
### When a closed conversation gets a new message
* **Always reopen it** (the default)
* **Reopen only within**, which reveals an hours box: anything from 1 to 8760 hours (a year), and 24 if you leave it alone
* **Leave it closed**
The tab's own explanation: **Someone writing back usually means the conversation is live again. Restrict it if your team treats closed as resolved.**
The window is measured from the moment the conversation was closed, not from the last message. A second message that arrives after the window has passed does not get a fresh window; it stays closed too.
This setting only decides the **Open** / **Done** flip. Whatever you choose, an inbound message is still recorded in the thread, still raises the unread count, still fires `message.received` and any automation built on it, and still un-archives an archived thread. So under **Leave it closed** a returning customer lands in **Done** carrying an unread badge, which is exactly why you would only choose it if someone watches **All** or **Unread only** rather than just **Open**. See [Open and done conversations](/inbox/conversation-status).
## AI replies
### Let the AI reply
A switch, **on** by default.
The tab's own explanation: **Turning this off stops every AI step across all your flows at once. The rest of each flow keeps running and the skipped step is noted in the conversation.**
This is the workspace kill switch for the **AI** step inside a [flow](/automation/flow-builder). Turn it off and no flow is broken: each one keeps running, the AI step is skipped, and a line appears in the conversation reading `✨ AI step skipped — AI replies are turned off for this workspace.` so whoever opens the thread can see why the bot went quiet. Turning it back on takes effect on the next message; it does not replay the skipped steps.
It does not touch **Suggest a reply with AI** in the Inbox reply box. That is a separate feature for your team, and it keeps working with this switch off. The AI agent itself is on every plan. What varies is the monthly AI replies allowance and whether you can connect your own provider key; see [Plans](/billing/plans).
### Wait before an AI reply
A number of seconds, **0** by default, maximum **5**.
The tab's own explanation: **A short pause makes the AI feel less instant. Max 5 seconds — it holds a worker while it waits. A flow step asking for longer keeps its own pause.**
It is a floor, not an override: an AI step that asks for a longer pause of its own (**Typing delay before reply (seconds)** on the step's **Prompt** tab in the [flow builder](/automation/flow-builder)) keeps the longer of the two. Five seconds is the ceiling either way, though: whichever number wins, the pause DMLY actually takes never exceeds five seconds, because it is a real wait held open while the reply is prepared. Anything longer needs a **Smart Delay** step before the AI step rather than a bigger number here.
## How the settings interact
The two halves of this tab meet in a few places worth knowing before you change anything:
| If you set | Then |
| - | - |
| **Change nothing — keep the bot running** | The resume policy has nothing to act on for replies, and greys out. It still governs a pause you start with **Pause bot (human takeover)**. |
| **Never — end it by hand** | The close prompt loses its **Resume it from the same step** option, and no other way of closing a conversation can hand the flow back either. |
| **Round-robin across the team** plus **Whoever replied last** | A new conversation gets an owner immediately, then changes hands to whoever answers it. Pair round-robin with **Only claim it if nobody owns it** if you want the original assignment to stick. |
| **Leave it closed** plus **Round-robin across the team** | A returning contact is never re-assigned: round-robin only ever fires on a contact's first message. |
| **Let the AI reply** off plus a flow whose only reply is an AI step | The flow still starts and still finishes, but the contact hears nothing: all they get is the skipped-step note, which only your team can see. Deactivate the flow itself if you want no run at all. |
Change one thing at a time and watch a real conversation. Every setting here alters something a customer experiences (whether the bot speaks, whether a chat reopens), and the defaults are the behaviour your team already learned.
## Related
Taking over from the bot, the parked-flow banner, and the close prompt.
Who owns a conversation, and what changes hands.
Marking done, reopening, and archive.
The first tab on the same Settings page.
# Custom media storage
Source: https://docs.dmly.io/account/custom-storage
Keep the photos, videos and files your customers send in your own AWS S3 or S3-compatible bucket instead of DMLY's storage.
By default, the media that arrives in your conversations is stored by DMLY. **Custom storage**
sends it to a bucket you own instead, on AWS S3 or any S3-compatible provider. Use it when your
privacy policy, a client contract or local data rules say customer files must stay in storage
you control.
## What goes to your bucket
Once it's on, these are saved to your bucket:
* Photos, videos, voice notes and documents customers send you on WhatsApp, Facebook,
Instagram, Telegram and Live Chat.
* Media brought across from the WhatsApp Business app when a number connects in
[co-existence](/channels/whatsapp).
* Files your team sends from the [mobile app](/getting-started/mobile-app).
Two things to know before you switch it on:
* **It applies to new media only.** Files already stored stay where they are. Nothing is moved.
* **Files in your bucket stay private.** DMLY never makes them public. When someone opens one in
the Inbox, DMLY creates a short-lived link for that view, so your bucket doesn't need public
access.
## Before you start
* **You need to be the workspace owner.** Only the owner can open or change this page.
* **Create a bucket** with your storage provider, and note its name and region.
* **Create an access key** that can read, write and delete objects in that bucket. On AWS, an
IAM user with a policy like this is enough (swap in your bucket name):
```json theme={"dark"}
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::your-bucket-name/*"
}
]
}
```
On another provider, create a key with read, write and delete rights on the bucket in its own
dashboard.
## What to enter
Here's what each field looks like on AWS. Use your own values, not these.
| Field | Example | Where to find it |
| - | - | - |
| **Bucket name** | `acme-salon-media` | The name you gave the bucket when you created it, exactly as shown in the S3 console. |
| **Region** | `eu-west-2` | The bucket's region code, shown next to it in the S3 console. For example `us-east-1` (N. Virginia), `eu-west-2` (London), `eu-central-1` (Frankfurt) or `af-south-1` (Cape Town). Use the code, not the city name. |
| **S3 endpoint URL** | Leave blank | Only needed for providers other than AWS. |
| **Path-style addressing** | Off | Leave it off on AWS. |
| **Access key ID** | `AKIAIOSFODNN7EXAMPLE` | 20 characters, starting `AKIA`. Shown when you create the IAM user's access key. |
| **Secret access key** | `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY` | 40 characters. AWS shows it only once, when you create the key, so copy it then. |
On an S3-compatible provider, the bucket and keys work the same way. The region and endpoint
come from your provider:
| Provider | Region | S3 endpoint URL |
| - | - | - |
| Cloudflare R2 | `auto` | `https://ACCOUNT_ID.r2.cloudflarestorage.com`, with your Cloudflare account ID |
| DigitalOcean Spaces | `nyc3` | `https://nyc3.digitaloceanspaces.com` |
| Wasabi | `eu-central-1` | `https://s3.eu-central-1.wasabisys.com` |
| Backblaze B2 | `us-west-004` | `https://s3.us-west-004.backblazeb2.com` |
These show one region each. Use the region your bucket is actually in, and the endpoint that
matches it. If saving fails with a provider like these, try turning on **Path-style
addressing**.
## Set it up
Go to **Workspace Settings → Custom storage** and turn on **Use custom storage**.
Fill in **Bucket name** and **Region**. On AWS, leave **S3 endpoint URL** blank.
On any other provider, paste its HTTPS endpoint into **S3 endpoint URL**. Your provider's
dashboard lists it. If the provider asks for it, turn on **Path-style addressing**.
Paste the **Access key ID** and **Secret access key**. Both are stored encrypted.
Select **Save storage settings**. DMLY writes a small test file to your bucket, reads it
back and deletes it before it switches anything over. If all three work, you'll see
**Storage settings saved.** and new media goes to your bucket from then on.
Changing the bucket, region or endpoint later doesn't need the keys again. Leave both key
fields blank to keep the saved ones.
## If saving fails
Nothing changes until the test passes, so media keeps going to the same place as before.
* *Could not verify storage. Check the endpoint, credentials, and bucket read/write/delete
permissions.* The test file couldn't be written, read back or deleted. Check the bucket name
and region, and that the key has all three permissions.
* *Use a publicly reachable HTTPS S3 endpoint without credentials or query parameters.* The
endpoint must start with `https://`, be reachable from the internet, and contain nothing but
the address. DMLY doesn't follow redirects, and won't connect to a private or internal
address.
## Switching back, or changing bucket
Turn off **Use custom storage** and save, and new media goes to DMLY storage again. Connect a
different bucket, and new media goes there.
Either way, DMLY keeps the old connection so it can still show the files already in that
bucket.
**Keep old buckets and their keys working.** Files saved to a bucket stay there. If you delete
the bucket or revoke its key, those files stop showing in your conversations. DMLY can't bring
them back.
**DMLY doesn't fall back to its own storage.** If your bucket stops accepting uploads, for
example because the key was revoked, new media isn't saved anywhere else. Check this page
after any change to your storage account.
# Set up notifications
Source: https://docs.dmly.io/account/notifications
Choose which events message your clients, which alert your team, and on which channel, all set once for the whole workspace.
DMLY sends messages automatically when things happen in your workspace. Some of those messages go out to a contact over WhatsApp, SMS or email. Some go to your team. Some are alerts that only ever appear inside DMLY.
All of it is controlled in one place, by an admin, for the whole workspace.
What an admin sets here decides whether an event fires at all, for everyone and every contact. In the mobile app (**Settings > Notifications**), each member can also turn push notifications for single events on or off under **Notify me about**, switch push off for their devices under **How to reach me**, and turn on a **Daily digest**. Those choices are per user and per workspace, and only affect push and the digest. The dashboard has no per-user notification settings. See [Profile](/account/profile) and [Security](/account/security).
## Who can change them
Notification settings live in **Workspace Settings → Settings → Notification settings**.
The link to Notification settings sits in the admin-only **Workspace Settings** area, so in normal use only an admin reaches it. See [Roles and permissions](/account/roles-and-permissions).
## The two tabs
Messages that leave DMLY (WhatsApp, SMS or email) and land with a contact or a member of your team.
Alerts that stay inside DMLY. Nothing here is ever sent to a client. See [Inbox notifications](/inbox/notifications).
That split is the one to keep in your head: **Client & staff messages** costs you a WhatsApp send and reaches a real person's phone. **In-app alerts** only ever reaches your own team, in the app.
## Which number they send from
The **Client & staff messages** tab opens with a **Default WhatsApp number** picker, above the event cards. It sets the number every WhatsApp notification sends from, and it is the number a first-time inbox message goes out on unless you pick a different one with the **Send from** picker in the [Inbox](/inbox/overview).
Set it before you look at any templates. The **WhatsApp template** dropdowns, and the **Approved**, **Pending** and **Rejected** badges beside them, only list templates approved on that number's WhatsApp Business Account, so changing the number changes which templates you can pick. The picker saves with the same **Save** button as the rest of the page.
The list only offers numbers that can actually send, and the picker is not there at all if you have none connected. A contact who already messages you on one of your numbers keeps that number; the default is what DMLY uses when there is no WhatsApp conversation yet. It is one setting for the whole workspace, not a per-event choice, and it does not appear on the **In-app alerts** tab.
## Customer-facing or team-facing
On the **Client & staff messages** tab, each card is one event and the cards are grouped under four headings. Each heading tells you who is on the receiving end:
* **Client notifications** go out to the contact.
* **Staff notifications** go to the staff member concerned.
* **Other notifications** go to your team: the admin, receptionist or scheduler-type members rather than the staff member on the booking.
* **Finance notifications** (invoice sent, payment receipt, gift card delivery, payment failed and renewal reminder) all go out to the contact.
**Payment receipt** goes out whenever a client pays you online — a booking deposit, a pay link from a flow or the AI agent, an invoice — and when you confirm a bank transfer they told you they sent. It does not go out for a payment you record by hand, so back-filling old payments never messages anyone. A subscription renewal has its own receipt, set on the subscription.
Each event is a card with a master toggle. The channel checkboxes (**Email**, **SMS** and **WhatsApp**) appear inside the card once the event is switched on, so you can send an event over WhatsApp only, or over WhatsApp and email, or turn it off entirely. WhatsApp is the one to lead with; leave the others off unless you have a reason.
## Turn a notification on
Go to **Workspace Settings → Settings → Notification settings**.
**Client & staff messages** for anything that leaves DMLY. **In-app alerts** for the alerts your team sees in the app.
Find the event's card and turn on its toggle. The channel checkboxes stay hidden until you do.
Tick **Email**, **SMS** or **WhatsApp**, whichever you want the event delivered over.
At the top of the **Client & staff messages** tab, pick the **Default WhatsApp number** these messages send from. If you have only one connected number it is already selected. Do this before the next step: it decides which templates the dropdowns offer.
A WhatsApp notification is carried by an approved WhatsApp template. Ticking **WhatsApp** reveals a **WhatsApp template** dropdown inside the card. It opens on the default option (labelled **Default template** followed by the template's own `dmly_` name) with an **Approved**, **Pending** or **Rejected** badge beside it. The client appointment confirmation, update and reminder events show two pickers instead: **WhatsApp template — with join link (Google Meet, Zoom, custom URL)** and **WhatsApp template — in person / no link**. Each has its own default and badge, and DMLY picks one per booking, so set both. The alternatives in the dropdown are your approved templates. See [Message templates](/broadcasts/message-templates).
Press **Save** in the page header. Nothing is stored until you do, and your changes are lost if you navigate away first.
The page states it plainly: **These settings apply to every contact in this workspace.** There is no per-contact exception and no opt-out list here.
## If a notification doesn't arrive
Work through it in this order:
* The event is switched on, and the channel is ticked, on the **Client & staff messages** tab, and you pressed **Save**.
* The channel itself is connected: a WhatsApp notification needs a connected WhatsApp number. See [Connect WhatsApp](/channels/whatsapp).
* The WhatsApp template it uses is approved by Meta. Without one, DMLY sends a normal WhatsApp message only while the contact's 24-hour window is open. Outside the window it falls back to the channel the contact arrived on (unless SMS is ticked for that event, when the SMS goes instead). If none of those apply, nothing goes out. See [Message templates](/broadcasts/message-templates).
If all three check out, see [Messages not sending](/troubleshooting/messages-not-sending).
## Next
Invite the people who receive staff notifications.
Who can reach Workspace Settings in the first place.
# Manage your profile
Source: https://docs.dmly.io/account/profile
Change your own name, email, language, timezone, password and two-factor authentication in DMLY.
Your profile is your own login account. It is separate from **Workspace Settings**, which
covers the business as a whole. See [Business details](/account/business-details). Every
user who logs in has a profile, whatever their role.
Select your name and avatar at the bottom of the sidebar to open your user menu, then select
**Edit Profile**. Everything below (your name, email and phone, **My Preferences**, two-factor
authentication, your password, and deleting your account, if your provider allows it)
happens on that one page.
The **Phone** field in **Profile Information** is optional. It fills in your number when
you are linked to a staff record.
## Change your email
Find the **Profile Information** section at the top and edit the **Email** field.
Select **Save**. The change takes effect immediately; there is no confirmation link sent
to the new address.
Email is what you sign in with, so the next time you sign in, use the new address. You stay
signed in on your current device right away.
## Set your timezone and other preferences
Find the **My Preferences** section and open the **Timezone** dropdown.
Pick your timezone and select **Save**.
The same section also holds your **language**, your **time format** (12 or 24 hour) and the
**first day of your week** (Sunday or Monday). These are simple dropdowns, saved the same way.
Your timezone is a personal preference; it does not change what anyone else sees, so two
teammates in different countries each set their own. The business-level timezone that
controls how times appear on your public booking page is separate, under **Appointments →
Settings**.
## Turn on two-factor authentication
Two-factor authentication lives on your profile, not on the workspace **Security** page;
that page only handles deleting the workspace. It is available by default, and each user
turns it on for themselves.
Scroll to the **Two Factor Authentication** section. It shows a red **Disabled** badge until
you turn it on. Select **Enable**.
If you don't already have one, install a TOTP-compatible authenticator app, such as Google
Authenticator, on your phone or computer.
DMLY shows a QR code and a secret key. Scan the QR code with your authenticator app, or
type in the secret key if you'd rather add it manually.
Type the 6-digit code your authenticator app shows into the **Code** field and select
**Confirm**.
The badge switches to **Enabled** and DMLY shows a set of recovery codes. Store them
somewhere safe, such as a password manager; they're the only way back in if you lose your
authenticator app.
DMLY may ask you to re-enter your password before this section unlocks. That's a routine
security check on a sensitive change, not something you need to redo afterward.
See [Security](/account/security) for single active session, roles, and what DMLY does and
doesn't protect against.
## Change your password
Scroll to the **Update Password** section.
Enter your **Current password**, then your **New password**, then repeat it in **Confirm
new password**.
Select **Save**.
Changing your password signs you out everywhere else. Every other browser session is signed
out the next time it loads a page, and every mobile app sign-in is revoked, including one on
your own phone, so you sign in again there. The browser you change it from stays signed in.
It does not touch workspace API keys or an AI client already connected over MCP. See
[what changing your password signs out](/account/security#what-changing-your-password-signs-out).
## What is not on your profile
Notification settings are workspace-wide and set by an admin. The dashboard has no
per-user notification settings. In the mobile app you can choose which events push to your
phone, switch push off, and turn on a daily digest. See
[Notification settings](/account/notifications).
## Delete account
**Delete account** permanently removes your login *and* every workspace you own, including
all of that workspace's data. It is a different action from **Delete workspace** on the
workspace **Security** page (which is owner-only and removes a single workspace), but it is
not the smaller one.
Workspaces you belong to but don't own are not deleted. You are simply removed from them,
and their owner keeps full access, even if you were the only Admin.
The **Delete Account** section only appears if your provider allows account deletion.
The business name, address, currency and logo for the whole workspace.
Invite teammates and set what each one can do.
# Control what your team can do
Source: https://docs.dmly.io/account/roles-and-permissions
Set a base role for each team member and build custom roles that grant an exact set of permissions.
Every person who logs in to your workspace has a **base role**: Admin, Member or Viewer. That role decides what they can open and change. When none of the three fits, you can build a **custom role** with an exact set of permissions and assign it to a member.
Only an Admin (or the workspace owner) can reach **Workspace Settings → Team Members**. The **Team Roles** page needs the *Create & edit custom roles* permission. Admins and the owner have it by default, and a custom role can grant it to a Member.
## The three base roles
Can access and edit everything, including billing, channels and team settings.
Can access and edit everything except the configuration section: a full day-to-day operator.
Can view the messaging modules (Inbox, Contacts, Broadcasts, Automation and so on) but cannot edit, send, or change configuration.
**Member** is the everyday role: it covers the work, but stops short of the destructive and admin-only actions. Those are deleting, importing or exporting contacts, uploading or deleting contact documents, managing letter templates, approving or deleting broadcasts, deleting automations and templates, seeing, changing or cancelling the whole team's appointments, booking or blocking time for other people, managing working hours, booking settings or class batches, deleting or importing offerings, issuing refunds, changing finance settings, editing business info on Google, exporting reports, viewing the audit log, setting up integrations, and anything under team settings.
**Viewer** gets read-only access and nothing else.
The workspace **owner** always has full access and carries an **Owner** badge in the members list. You can't change the owner's role or remove them, and you can't change your own. There's no way to hand a workspace to a different owner from inside the app. Contact your agency or DMLY support if you need that.
How many people you can have in a workspace depends on your plan. Everyone who has joined counts toward the limit, whatever their role. So do pending invitations and active appointment staff without a login. See [What uses up a seat](/account/team-members#what-uses-up-a-seat), [Plans](/billing/plans) and [Add-ons](/billing/add-ons).
## When to build a custom role
DMLY ships **no ready-made custom roles**. A new workspace has the three base roles and nothing else, which is why the Team Roles page starts empty.
Build one when you need someone in between: more than a regular Member, less than an Admin. A bookkeeper who should issue refunds and change finance settings but touch nothing else. A marketer who can approve and send broadcasts but can't delete contacts. A manager who can see the whole team's appointments.
If a base role already fits, use it. A custom role is extra to maintain.
## The module and action model
A custom role is built from **68 permissions** grouped into **13 modules**:
| | |
| - | - |
| **Inbox** | **Contacts** |
| **Broadcasts** | **Automation** |
| **Templates** | **Appointments** |
| **Offerings** | **Finance** |
| **Reputation** | **Reports** |
| **Growth Tools** | **Setup & Integrations** |
| **Team & Settings** | |
Each module holds separate actions (viewing, creating, editing, deleting, exporting and so on), so you grant the specific thing rather than the whole module. You can tick a module whole, or tick individual permissions inside it.
Permissions are enforced when a page loads, not just by hiding menu items. A member without any permission in a module can't reach it even with a direct link.
## Build a role
Go to **Workspace Settings → Team Members** and select **Team Roles** in the panel header. Select **Back to Team Members** to return.
Select **New role**, name it something a person would recognise on a picker ("Bookkeeper", "Front desk") and open the **Permissions** matrix.
Work module by module. Tick a whole module when the role owns that area, or tick individual actions when it doesn't. Start narrow; you can always add more later.
The role now appears on the Team Roles list, ready to assign. Creating a role changes nobody's access until you assign it.
Grant a view permission wherever you grant an edit permission. A role built from edit permissions alone will reach the module, but pages and lists it can't view will be incomplete.
## Assign a role
Members are invited by email; there's no way to add someone directly.
Go to **Workspace Settings → Team Members** and select **Invite member**. Enter their email address and pick a base **Role**.
Under **Custom role**, choose the role you built, or leave **None — use the base role above** to stay on the base role.
Select **Invite**. The invitation appears at the top of the members list with a **Pending** badge until they accept.
To change an existing member, open the actions menu on their row and select **Edit role**. You can set the base role and the custom role there, plus two more things:
* **Profiles**: pick connected profiles to limit a member to those profiles and the conversations that arrive on them. None selected means every profile. This applies to everyone below Admin, with or without a custom role.
* **Access restrictions** (only when no custom role is picked): hide modules such as Inbox, Appointments, Broadcasts, Automation or Reports, mask phone numbers and emails, and allow approving broadcasts.
A pending invitation's role can't be edited. If you picked the wrong one, select **Cancel** on that row and send a fresh invite.
## How access is decided
DMLY works down this order for every member:
1. **Owner or base role Admin**: full access, always. A custom role assigned on top of Admin makes no difference.
2. **A custom role is assigned**: exactly the permissions ticked on that role, and nothing else. The custom role replaces the base role's defaults.
3. **Base role Viewer**: view permissions only.
4. **Base role Member**: the standard operator set described above, narrowed by any **Access restrictions** set on that member.
A member's **Profiles** selection applies on top of all of this, for everyone below Admin.
So a custom role decides access for base role Members. Base role Viewers stay read-only whatever the role grants: a Viewer with a custom role that grants editing or sending still can't write anything. Put someone on **Member** before assigning a custom role that needs to edit or send.
Members on that role revert to their base role (Member or Viewer) and keep working. Nobody is locked out. The confirmation says so before you delete.
Check their row in **Team Members** first. If they have a custom role, the base role is being ignored; the fix is a permission on the role, not a different base role. Then check that the role has the module's view permission as well as the action they're trying to take.
If their base role is **Admin**, no custom role will narrow them. Drop them to **Member** and let the custom role do the work.
## Related
Invite people, cancel invitations, and remove members.
How many members your plan allows.
Workspace settings an Admin controls.
Owner-only workspace actions.
# Secure your account
Source: https://docs.dmly.io/account/security
Turn on two-factor authentication, control who can do what in your workspace, and understand what DMLY protects and what it doesn't.
DMLY's account security rests on three things: each person's own login controls (their
password, two-factor authentication, and an optional single-session lock); the role you
give them in the workspace; and the fact that the most destructive actions are restricted
to the workspace owner. There is no separate security dashboard beyond that; this page is
an honest account of what exists.
## Turn on two-factor authentication
Two-factor authentication lives on each person's own profile, not in workspace settings.
It is per user: you turn it on for your own login, and each team member turns it on for
theirs.
Open **Edit Profile** from your account menu.
Find the **Two Factor Authentication** section and follow the setup there.
The same page is where you change your password, your name and email, your language and
timezone, and where you can delete your own account. See [Your profile](/account/profile).
Two-factor authentication starts switched off; you have to enable it yourself. Until you
do, the section shows a red **Disabled** badge and an **Enable** button.
DMLY has no workspace-wide setting that forces every member to use two-factor
authentication, and no admin can turn it on for someone else. If you want it across your
team, ask each person to switch it on and check with them.
## Limit yourself to one active session
**Single active session** is also on your own profile page, not workspace settings. It's
per user; you turn it on for your own login, and it has no effect on anyone else's. It is
one lock covering everywhere you sign in, browsers and the
[DMLY mobile app](/getting-started/mobile-app) together, not one lock per surface: one
signed-in session at a time for your whole account, and whatever signs in next takes the
slot from whatever held it.
Open **Edit Profile** from your account menu.
In the **Single active session** section, switch on **Only allow one active session**.
Confirm **Enable single active session?**. This claims the slot for the browser you're
on right now: every other browser session is signed out the next time it loads a page,
and every mobile app sign-in is revoked, including one on your own phone.
Single active session starts switched off, the same as two-factor authentication.
Nothing changes until you turn it on. Turning it back off is immediate and needs no
confirmation; it just stops the check. It doesn't sign anyone out, and it doesn't restore
a mobile app sign-in that enabling it revoked: that phone has to sign in again.
It isn't a lock to one specific device; it's a lock to one session at a time. Whichever
browser signs in next, on any device (you on a different computer or phone browser, or
anyone else who has your password), claims the slot, signs out whatever browser session
held it before, including the one you turned the setting on from, and cuts off the mobile
app as well. Turning it on also switches off "remember me" for your account, so every
browser needs a fresh sign-in once its session ends.
The [DMLY mobile app](/getting-started/mobile-app) shares that one slot rather than having
its own, so the two surfaces do evict each other:
* **A browser claims the slot** (you sign in on a browser, or turn the setting on there),
and the app's sign-in is revoked. The next thing the app tries to do is refused and it
drops you back to its sign-in screen, and that device stops receiving push notifications
for the account until you sign in again.
* **The app claims the slot** (you sign in on a phone), and every browser session is signed
out the next time it loads a page, landing on the login screen. Another phone already
signed in loses its sign-in too.
Neither direction is a live push-based sign-out. The eviction lands on the next request, so
a page or a screen already open can sit there looking signed in until something touches it.
## What changing your password signs out
If you think someone else is in your account, changing your password is the right first
move. It is not only a new password: it evicts the sessions the old one could still reach.
It signs out:
* **Every other browser session**, on any device, the next time that session loads a page.
The check runs on each request, so a browser sitting on an open page is cut off as soon as
it does anything.
* **Every mobile app sign-in**, including the one on your own phone, straight away. The app
drops back to its sign-in screen and stops receiving push notifications for the account
until you sign in again.
* **Every "remember me" cookie**, so a device that was remembering you can't quietly sign
itself back in.
Using the emailed **Forgot your password?** link does the same, which matters because that
is the route you take when you've already been locked out: it revokes the mobile app and
leaves every browser session to be evicted on its next request, then puts you on the login
screen to sign in fresh.
It does **not** sign out:
* **The browser you changed it from.** That session deliberately stays signed in, so you
don't lock yourself out mid-task.
* **Workspace API keys.** A key is a workspace credential, not your login, and keeps working
on your old password. If a key might be exposed, revoke it from **Integrations**. See
[API authentication](/api-reference/authentication).
* **An AI client already connected over MCP.** Its access rides on the authorization the
connection was granted, not on your password, and DMLY has no page to cut one off from:
disconnect it in the client that holds the connection, or revoke the API key it uses. See
[the MCP server](/mcp/overview).
Belt and braces during a suspected compromise: change your password, then turn on
**Single active session** so the next sign-in anywhere evicts everything else too, and
turn on two-factor authentication so a stolen password isn't enough on its own.
## Control who can do what
The strongest lever you have is roles. Everyone who logs in has a base role of **Admin**,
**Member**, or **Viewer**, and you can build custom roles that grant an exact set of
permissions.
Two boundaries are worth knowing:
* **Workspace Settings is admin-only.** Members and Viewers cannot reach **Settings**,
**Team Members**, **Billing**, or **Receipts** at all: not by clicking, and not by
typing the URL. The exception is the tabs on the **Settings** page that carry a permission
of their own, **Audit log** among them: a custom role can grant a Member one of those
without opening up the rest.
* **Security is owner-only.** Only the workspace owner sees the **Security** page.
Permissions are enforced on the server, not just hidden in the sidebar, so a member who
knows a URL still can't use a feature they lack permission for.
Invite people, set their base role, and remove access when someone leaves.
Build a custom role with exactly the permissions you want to grant.
Removing someone's access is a team-members task, not a security-page task. When a person
leaves, remove them from **Team Members**; that is what cuts off their access to the
workspace.
## The Security page
**Workspace Settings → Security** contains one thing: deleting the workspace. It is visible
only to the workspace owner.
**Deleting this workspace will delete all data.** Deletion is permanent, and any active
subscription is cancelled immediately. You have to type the workspace name to confirm.
There is no undo and no export step built into the flow, so take what you need out first.
See [Exporting reports](/analytics/exporting-reports) and
[Contact data and privacy](/contacts/data-and-privacy).
If what you actually want is to stop paying, cancel the subscription instead; that is
reversible and leaves your data alone. See [Cancelling](/billing/cancelling).
## What DMLY does not have
Saying this plainly is more useful than implying protection that isn't there:
* **No per-device session list.** You cannot see which devices are signed in, and you
cannot pick a single one to sign out. The two levers you have are blunter: each clears
everything except the session doing the clearing. Those are changing your password (above)
and **Single active session**, which repeats that eviction every time your account signs
in anywhere while it's on.
* **A partial audit log, not a complete one.** There *is* a record you can browse of who did
what: **Workspace Settings → Settings → Audit log**. It covers deletions, bulk contact
changes, service imports, an agency opening your workspace, every write an AI client
makes over MCP, and some money and data actions: bank transfers confirmed or rejected,
refund reversals, changes to the manual transfer bank account, contact restores and
recovery purges, Live Chat widgets retired or erased, removals from a sequence, staff
Google Calendar disconnects and review attribution corrections. It does not cover most everyday admin changes, so don't read an empty log
as proof nothing happened. See [Audit log](/account/audit-log).
* **No IP allowlist** and no login-location restrictions.
* **No ownership transfer.** A workspace owner cannot hand the workspace to someone else
from workspace settings. Only the platform operator can change a workspace's owner, so ask
your agency or DMLY support.
A workspace API key is a credential, so treat it like a password and never paste it into a
chat or a public page. Keys are minted from **Integrations**, not from the Security page.
See [API authentication](/api-reference/authentication).
# Invite and manage team members
Source: https://docs.dmly.io/account/team-members
Invite teammates to your workspace by email, give them a role, and remove or revoke access when they leave.
Everyone who works in your workspace needs their own login. You add them by sending an email
invitation and choosing what they can do. Team members live under **Workspace Settings → Team Members**,
which only workspace admins can open; a Member or Viewer can't reach the settings area at all.
## Invite someone
There is no way to create a login for someone else or add an existing user directly. Everyone
joins by accepting an emailed invitation, so the person who joins owns their own password.
Go to **Workspace Settings → Team Members** and select **Invite member**.
Use the address they'll sign in with. The invitation goes to that address.
Choose **Admin**, **Member** or **Viewer** (see below). If you've built a custom role,
you can pick it under **Custom role** instead; leave it on **None — use the base role above**
to stick with the base role.
Select **Invite**. The invitation appears at the top of the members list with a **Pending**
badge until they accept.
Without a custom role, a new member joins with their base role's full access. The invite form
has no other access controls.
## Fine-tune a member's access
Once they've joined, open the menu on their row and choose **Edit role**. There you can:
* Tick the **Profiles** they can work with.
* Tick **Can approve broadcasts** to let them approve scheduled broadcasts before they send.
The members list shows this in its **Can approve** column.
* Under **Access restrictions**, tick **Mask contact phone numbers**, **Mask contact email
addresses**, **No Inbox module access**, **No Appointments module access**, **No Broadcast
module access**, **No Automation module access** or **No Report module access**.
**Can approve broadcasts** and **Access restrictions** are hidden when a custom role is picked,
because the role then defines the member's access.
## The three roles
Every team member has one of three base roles.
Can access and edit everything, including settings, billing and the team itself.
The everyday operator role: they work the inbox, contacts, their own appointments and
campaigns. They can't change how the workspace is set up, and some actions stay with
admins: deleting, importing or exporting contacts, contact documents, approving or deleting
broadcasts, deleting automations or templates, seeing or editing other staff's appointments,
working hours and appointment settings, refunds and finance settings, report export, channels
and API keys, the team and the audit log.
Read-only: they can view Offerings, Finance, Reputation, reports and logs, and they see full
contact phone numbers and email addresses. In Appointments they see only bookings on their
own staff row, and they can't open the audit log. They cannot edit, send, or change
configuration.
If a base role plus the **Access restrictions** checkboxes still isn't granular enough,
build a custom role with exactly the permissions you want and assign it when you invite. A custom
role replaces the base role's access, except for **Admin**, which always has full access
regardless. See [Roles and permissions](/account/roles-and-permissions),
reachable from the **Team Roles** button in the **Team Members** panel.
## What uses up a seat
Your plan sets how many people can be in the workspace, and three things count against it:
* **People who have joined**, the obvious one.
* **Invitations that have not been accepted yet.** If you invite someone and they never accept,
that seat stays used until you cancel the invitation.
* **Active [appointment staff](/appointments/staff-and-availability) who have no login of their
own.** This is the one that catches people out. A stylist or practitioner you added purely so
bookings can be assigned to them takes a seat exactly as a teammate does, even though they
never sign in. Three unlinked staff on a five-seat plan leave you two seats for actual logins.
Deactivating a staff member releases their seat.
When you're full, the invite form shows `Members limit reached. Max limit: N` (N is your seat cap, add-ons included) and won't send. Two ways out:
Free up seats people never claimed. See below.
Move to a plan with a higher limit, or buy an **additional teammate** add-on.
The limit depends on your plan. Check [Plans](/billing/plans) for yours, or
[Add-ons](/billing/add-ons) to buy extra seats without changing plan.
## Cancel a pending invitation
Open the menu on the invitation's row and select **Cancel**, then confirm. The link stops
working and the seat is freed immediately.
You can't change the role on an invitation that's still pending. Cancel it and send a new one
with the right role.
## Remove someone
Open the menu on their row and select the remove option, then confirm. They lose access to the
workspace straight away, and the seat is freed.
Two rows never show an actions menu:
* **Yourself.** You can't remove your own access or change your own role.
* **The workspace owner**, who carries an **Owner** badge instead of a role badge.
There is no way to hand a workspace over to a new owner from within DMLY. If the owner is
leaving the business, contact support before you remove their user; removing them does not
transfer ownership.
## Next steps
Build a custom role with exactly the permissions you want.
Choose which events alert your staff.
See what your plan includes.
Buy extra teammate seats on top of your plan.
# Brand DMLY as your own
Source: https://docs.dmly.io/agency/branding
Set your app name, logo, colour and Powered by link so your clients see your brand instead of DMLY.
Your branding lives in the reseller dashboard and applies to everything your clients see: the
app name in their browser tab, the logo in the sidebar, the accent colour, and the **Powered
by** line on your public booking pages and invoices.
In the browser, branding only applies on **your own domain**. On the DMLY app host your
clients see DMLY's own name, logo and colours. Connect a custom domain first. See
[Custom domain](/agency/custom-domain). The DMLY mobile app is different: once a client signs
in, it shows your app name and your accent colour, because it follows the workspace's agency.
While your agency is suspended, your domain still shows your app name and colours, sign-in
page included. Custom scripts stop and your sub-accounts are locked. The mobile app falls back
to DMLY's branding.
## Set your brand
Select **Reseller Dashboard** from your user menu, then go to **Settings**.
Set **Agency name**, **App name (shown to customers)**, **Logo URL**, **Dark-mode logo
URL**, **Favicon URL**, **Powered by link**, **Help centre link** and **Primary color**.
**App name (shown to customers)** is what replaces "DMLY" throughout the product: the
browser tab title and the app name your clients read. **Agency name** is how your agency
is identified internally; it is only used as a fallback if **App name** is blank.
Select **Save brand**. You see **Settings saved.** and the change applies on your next
page load on your branded domain.
### Logo
**Logo URL** takes a link to an image you host, or select **Or upload a logo** to upload one
(PNG, JPG or WebP, up to 2 MB and 4096 × 4096). An upload replaces the URL. It replaces the DMLY
logo for your clients. If you leave it blank, your clients see the DMLY logo.
**Dark-mode logo URL** sets a separate logo for clients using dark mode, and can be uploaded the
same way. Leave it blank and your main logo is used in dark mode too.
### Favicon
**Favicon URL** sets the small icon in the browser tab, or select **Or upload a favicon** (PNG or
JPG, up to 1 MB and 1024 × 1024; a square image works best). Your clients see it in the
dashboard's browser tab on your branded domain and in the Live Chat widget.
### Colour
**Primary color** is a single colour. There is no full palette editor; you pick one colour
and DMLY applies it to buttons, links and accents across the app. The lighter tints and
darker shades the app needs, such as tinted backgrounds and focus outlines, are worked out
from that one colour.
The colour is saved as a six-digit hex code like `#4f46bb`, which is what the picker always
gives you. Anything else is refused with *Colours must be 6-digit hex codes, like #4f46bb.*
under the field.
### Help centre link
**Help centre link** takes a link to your own documentation. It becomes the **Help Center**
item at the foot of your clients' sidebar, and opens in a new tab. It saves with the rest of
the Brand section under **Save brand**, not with a button of its own.
Leave it blank and that menu item does not appear for your clients at all; they are never
sent to DMLY's documentation. Your own workspace is the exception on the DMLY app host. You are a DMLY
customer underneath your agency, so there you keep DMLY's docs whatever you set here. On your
own domain the item is hidden for your workspace.
The value must be a full `http://` or `https://` address, up to 2,048 characters. Typing
`help.acme.com` without the scheme is rejected with a validation error under the field.
The two labels are spelled differently. The field you fill in is **Help centre link**; the
menu item your client clicks is **Help Center**.
## What Powered by renders
**Powered by** appears on your public [booking pages](/appointments/overview), on the HTML
version of an [invoice](/finance/invoices), in notification emails, and in the footer of the
[Live Chat widget](/channels/livechat-widget) your clients embed on their own sites. It reads as
your brand, not DMLY's:
* The label is your **App name (shown to customers)**. If that's blank, it falls back to your
**Agency name**.
* It links to your **Powered by link**. If you leave that blank, it links to your custom
domain instead. If you have neither, the label renders as plain text with no link.
There is no switch to remove **Powered by**. It always renders in those places; the most
you can do is make it point at you. It is omitted from the invoice PDF.
## What you cannot change
The DMLY mobile app keeps DMLY's own icon. Your favicon only changes the browser tab and the
Live Chat widget.
It renders on booking pages, HTML invoices, notification emails and the Live Chat widget
footer, with no toggle.
One **Primary color**, no palette editor.
## Sending email as yourself
Branding your app name doesn't change who your clients' emails come from. To send from your
own domain, fill in the **Email (SMTP)** section on the same **Settings** page and select
**Save SMTP**. As the confirmation says: set SPF/DKIM for your domain so those emails land in
inboxes.
## Adding your own scripts
The **Custom scripts** section on **Settings** adds your own code (analytics, pixels, a chat
widget) to pages on your domain. Paste it into **Scripts inside the `` tag** or
**Scripts before the `` tag**, then select **Save scripts**.
Only the agency owner can see or change this section. Team members see *Only the agency owner
can view or change custom scripts. Ask them if you need analytics, a chat widget or a pixel
added.* in its place, whatever their role. No role you build on **Team** can grant it: the
**Edit sign-up page and customer workspace options** permission covers the
**Customer signup & workspaces** panel, not scripts.
That permission used to be called **Edit signup and custom scripts**, and it let team
members save scripts. If you ever gave it to anyone, open **Custom scripts** and check that
everything there is code you put there or recognise.
### Where your scripts run
Your scripts are added in two places, both on your domain:
* **Your public sign-up page**, for visitors who aren't signed in. It's the only page a
signed-out visitor gets them on.
* **Your customers' dashboard pages**, such as the inbox, the members page and their billing
page, when a customer of your agency is signed in.
They are deliberately left off these pages:
* Your Reseller Dashboard, and the page a new team member opens from their invitation.
* Sign-in, password, two-factor and email verification pages.
* A customer's profile and security pages, their workspace **Settings** page (where they create
API keys) and **Workspace Services** page (where their own service credentials are saved), the
integration pages where they manage API keys and other credentials, and the pages where they
connect WhatsApp.
And they don't run at all for these people, on any page:
* You, and anyone on your team or on another agency's team.
* DMLY staff.
* Anyone inside a client workspace through **Open**, including you.
* A customer who also belongs to a workspace outside your agency.
Scripts also stop running while your agency is suspended, and never run on the DMLY address.
Because your own visits are excluded, you won't see your scripts load while you're signed
in. To check one is working, open your sign-up page in a private window, or sign in with a
customer account that belongs only to your agency.
A script runs with the access of whoever loaded the page. Once it loads on a customer's
dashboard it keeps running as they move around the app, including onto pages it is never
added to, and it can do anything that customer can do. Only paste code from a source you
trust, and check it before you save.
## Next
Branding needs your own domain. Connect one and verify its DNS.
Assign plans, suspend or open a client's workspace.
Bill your clients through your own Stripe account.
How the reseller layer fits together.
# Client workspaces
Source: https://docs.dmly.io/agency/client-workspaces
How client workspaces join your agency, and how to assign plans, grant add-ons, suspend access, and open one as the customer.
Every workspace you sell sits under your agency as a **sub-account**. You manage them all
from the reseller dashboard: assign one of your plans, grant an add-on, suspend or restore
access, and open a workspace to work inside it.
The **Reseller Dashboard** link only appears in your user menu once your workspace is on a
reseller plan. If you don't see it, see [Agency overview](/agency/overview).
Open it from your user menu → **Reseller Dashboard**, then select **Sub-accounts**. The list
shows each workspace's **Name**, **Plan**, **Contacts**, **Add-ons** and **Status**.
## How client workspaces are created
There is no button to create a client workspace in the reseller dashboard, by design.
Workspaces arrive one of four ways:
Your branded sign-up page creates the customer's account and their first workspace under
your agency, on your first enabled plan. See [Custom domain](/agency/custom-domain).
`POST /workspaces` on the Agency API creates the owner's user account and their workspace
under your agency in one call, on a plan you name or your first enabled plan. The response
includes the owner's name and email. See
[Agency API](/api-reference/agency-introduction).
A workspace created by someone who already belongs to your agency stays under your
agency automatically.
The workspace you were in when you took a reseller plan is moved under your agency
automatically, which is why it shows in this list.
The empty state points at the first of those: *No sub-accounts yet. Customers who sign up on
your domain appear here.*
The business name your customer types at sign-up becomes the workspace name exactly as
entered; it is the business name throughout the product. See
[Business details](/account/business-details).
Whether customers can create a *second* workspace is your call, not the platform's. It's
controlled by **Customer signup & workspaces** in **Settings**, where **Off closes your
public sign-up page** turns sign-ups off entirely. Most agencies sell one workspace per
customer and leave the extra-workspace setting off.
### What your sign-up page says
The same **Settings → Customer signup & workspaces** panel controls the wording on your
branded sign-up page. Alongside the two Yes/No dropdowns it carries three copy fields, all
saved with **Save signup settings**:
* **Sign-up form title** is the heading. It starts out as *Create your account*, and clearing
it falls back to that same wording. Up to 255 characters.
* **Sign-up form description** is the line under the heading. It starts out as *Start your
free trial.* Clear it and no description line shows at all. It is plain text: line breaks
are kept, HTML tags are not interpreted. Up to 500 characters.
* **Terms text (shown by the accept checkbox)** changes behaviour, not just wording. Write
anything here and your sign-up page gains a tick-box carrying that text, which the customer
must tick before the account is created. Leave it empty and there is no checkbox and no
consent step. Up to 2,000 characters.
The terms text is plain text too. Line breaks are kept, and HTML tags show on the page exactly
as you typed them. To link to your own terms page, paste its full address: an address that
starts with `https://` becomes a link that opens in a new tab.
```text theme={"dark"}
By signing up you agree to our terms: https://acme.com/terms
```
Only `https://` addresses become links; an `http://` address stays plain text. A full stop or
closing bracket straight after the address is left out of the link, so the address can end a
sentence.
Terms text used to accept HTML. If you had saved a link such as
`Terms`, it was converted for you to
`Terms (https://acme.com/terms)`, which still shows as a link. Where a link was followed
straight away by more text, a space was added after the address so each link still opens the
right page. Other tags were removed and their text kept. Read your terms once in the panel to check they still say what you want.
### The sub-account limit
The number of client workspaces you can carry depends on your reseller plan. When you hit
it, the **Sub-accounts** page shows *You've reached your sub-account limit. Contact support
to raise it.*, and your branded sign-up page stops accepting new customers. See
[Plans](/billing/plans).
## Assign a plan
A new sub-account lands on the first enabled plan in your plan list; there is no separate
"default plan" setting. If you have no enabled plans yet, it lands with no plan at all. To
move a sub-account to a different plan:
Plans live under **Configuration → Plans** in the reseller dashboard. Each plan sets the
limits the workspace gets. See [Plans and billing](/agency/plans-and-billing).
On **Sub-accounts**, use the **— assign plan —** dropdown on the workspace's row and
choose the plan. You'll see *Plan assigned to* the workspace name.
Assigning a plan copies that plan's limits onto the workspace immediately. The customer
keeps whatever they've already set up; only the limits change.
A plan that's assigned to a sub-account can't be deleted; you'll get *This plan is
assigned to sub-accounts. Reassign them before deleting it.* Move those workspaces to
another plan first.
## Grant an add-on
When a client needs more than their plan allows, grant an add-on instead of moving them up
a plan.
Under **Configuration → Plans**, switch on **Add-on plan**, pick an **Add-on type**:
**Extra contacts**, **Extra profile per channel** or **Additional teammate**. Then
set **Quantity granted (per unit)**.
Your add-on plan then appears in the **+ grant add-on** dropdown on each sub-account's
row, listed by the name you gave it. Until you create one, the dropdown doesn't appear
at all. Granting adds that capacity to the workspace's limits straight away; grant again
to add another unit.
Each granted add-on shows as a chip on the row, for example `+500 contacts`. Select the
**×** on it to take it back.
Add-ons you grant are billed by you, to you; there is no checkout for the customer, and
your clients never see DMLY's own add-on catalogue or prices.
## Open a client workspace
**Open** signs you in to the workspace as its owner. You get exactly what the owner gets:
the full inbox, contacts, automations, appointments, finance. Nothing is hidden from you.
### Opening a workspace is recorded in the client's own log
Before you are signed in, DMLY writes an entry into that workspace's **Audit log**: the
action `agency.impersonate`, the summary *Agency opened this workspace as its owner*, your
name in the **Who** column, the IP address you opened it from, and the time. The entry
belongs to the client's workspace, not to your agency, so your client can read it: the
workspace owner, their Admins, and anyone whose role grants **View the audit log** see it
under **Settings → Audit log**. See
[Read the workspace audit log](/account/audit-log), which also covers how long entries are
kept.
What the entry does not do is label the work you then do. From the moment you are signed in,
DMLY treats you as the owner, so anything you do inside the workspace that writes its own
audit entry is attributed to the owner's account rather than to you. The log shows that you
opened the workspace and when; it does not separate your actions inside it from the
customer's own.
While you're inside the workspace, your user menu shows an **Impersonating** badge. Select
**Sign Out** in that menu; while impersonating it ends the session-as-owner and drops you
back into your own account rather than logging you out.
Because **Open** gives you the owner's full view, it includes your client's customer
conversations and contact data. Treat it as access to their business, and tell your clients
you have it before they find the entry themselves: every time you use **Open** leaves your
name in their audit log. See [Contacts data and privacy](/contacts/data-and-privacy).
Two cases where **Open** won't work:
Nobody owns the workspace, so there's no account to sign in as. This is rare; it usually
means the owner's user account is gone.
The workspace is one of your own, not a client's. Switch to it from your workspace
switcher instead.
## Suspend and restore
**Suspend** locks a client out of their workspace. Use it when they stop paying you. Their
data stays exactly where it is; only access stops. **Restore** puts it back. The **Status**
column shows **Active** or **Suspended**.
If **your** agency is suspended (most often because your own reseller subscription to
DMLY lapsed), every one of your client workspaces is locked at once, including ones you
never suspended and clients who are fully paid up. Restoring your own subscription lifts
it. Keep your reseller plan current. See [Plans and billing](/agency/plans-and-billing).
## What clients see
A client in one of your workspaces sees your brand, not DMLY's, and their **Billing** menu
goes to *your* plans, charged through *your* Stripe account. They never see DMLY pricing.
Set that up in [Plans and billing](/agency/plans-and-billing) and
[Branding](/agency/branding).
Build the plans you sell and connect your own Stripe account.
Serve your agency (and your sign-up page) on your own domain.
Set the app name, logo and colour your clients see.
How the agency layer fits together.
# Connect Stripe
Source: https://docs.dmly.io/agency/connect-stripe
Connect your agency's own Stripe account so client workspaces can subscribe and pay you online, and set up the webhook that keeps DMLY in sync.
This page is about the Stripe account **you, the agency**, connect so your client workspaces
can pay *you* for their plans. It is not the Stripe gateway a business connects under
Finance to collect invoice payments from its own customers. For that, see
[Payments & gateways](/finance/payments-and-gateways).
## What connecting Stripe unlocks
You can run an agency without Stripe: you assign plans to clients by hand from
**Sub-accounts**, grant add-ons, and collect payment however you like outside DMLY. What
Stripe adds is the self-serve loop: clients subscribing and paying you online, without you
touching anything.
Your clients' **Plans & billing** page shows your plans, but every button is disabled
and the page reads **Online billing isn't available yet.** with your support email. You
assign plans manually from **Sub-accounts** and handle money outside DMLY.
**Choose plan** goes live. A client picks a cycle, lands on a Stripe-hosted checkout
against **your** Stripe account, and pays you directly. DMLY never sees the money and
takes no cut.
Two things stay the same either way: add-ons for client workspaces are always granted by
you from **Sub-accounts** (clients can't buy them at checkout), and charges, refunds,
disputes, and payouts live in your Stripe dashboard, not in DMLY.
Connecting Stripe has two halves, and **both matter**. The keys let DMLY start a checkout;
the webhook is how Stripe tells DMLY the payment happened. Skip the webhook and clients can
pay you while DMLY never hears about it.
## Enter your keys in DMLY
In your Stripe Dashboard, find your API keys under the developers section. You need two
values: the **secret key** (starts `sk_live_`) and the **publishable key** (starts
`pk_live_`).
In your agency dashboard, go to **Configuration → Settings** and find the
**Stripe (charge your customers)** panel. The badge next to the heading reads
**Not connected** until a secret key is saved.
Fill in **Secret key** and **Publishable key**, then select **Save Stripe keys**. You
can leave **Webhook signing secret** empty for now; you'll get that value from Stripe
in the next section and come back.
On success you see *Stripe keys saved. Your customers will be billed through your own
Stripe account.* and the badge switches to **Connected**.
Your keys are stored encrypted. The **Secret key** and **Webhook signing secret** are never
shown back to you (those fields always render empty), and only the publishable key is
prefilled on your next visit.
DMLY does not test your keys when you save them. A mistyped secret key saves fine and the
badge still says **Connected**; the error only surfaces later, when a client tries to
check out. Copy the keys carefully, and run the end-to-end test below before pointing a
real client at the page.
## Create the webhook endpoint at Stripe
After a client pays, Stripe reports the subscription back to DMLY over a webhook. Without
it, DMLY records nothing (no subscription, no receipts) even though the charge went
through.
Your webhook URL is shown in the same Stripe panel, under **Your webhook endpoint**.
Copy it from there. It looks like:
```text theme={"dark"}
https://dash.dmly.io/agency//stripe/payment-webhook
```
Use the URL exactly as shown. It's built on DMLY's own host on purpose, so it keeps
working even if you later change your agency's custom domain.
In your Stripe Dashboard, create a new webhook endpoint (under the developers /
webhooks section) and paste the URL above as the destination.
When Stripe asks which events to send, select these seven. This is the part that most
often goes wrong:
* `invoice.payment_succeeded`: records a payment receipt in DMLY
* `customer.subscription.created`: records the new subscription
* `customer.subscription.updated`: keeps its status, price, and trial in sync
* `customer.subscription.deleted`: marks the subscription cancelled
* `customer.updated`: refreshes the stored payment method
* `customer.deleted`: clears the customer link from the workspace
* `payment_method.automatically_updated`: keeps card details current
Don't select "all events" instead: DMLY quietly accepts event types it doesn't handle,
so every delivery looks successful in Stripe while the noise buries the ones that
matter.
Stripe shows a **signing secret** for the new endpoint, starting `whsec_`. Copy it,
return to **Configuration → Settings → Stripe (charge your customers)**, paste it into
**Webhook signing secret**, re-enter your **Secret key**, and select **Save Stripe
keys** again.
Every save needs **Secret key** and **Publishable key** filled in, and the secret key field
starts empty on each visit, so paste it again whenever you save. **Webhook signing secret**
is the exception: leave it blank and DMLY keeps the secret already stored, so re-saving the
panel to fix a typo or rotate a key can't switch your webhook off. To replace the signing
secret, paste the new `whsec_` value in and save; the panel has no way to clear one once
it's stored.
## Give your plans Stripe prices
A plan can only be bought online when its billing cycle carries a Stripe price. In Stripe,
create a recurring price for each plan and cycle you sell; then, on your agency **Plans**
page, paste each price's ID (starts `price_`) into **Monthly Stripe price ID** or
**Yearly Stripe price ID** on the matching plan. A cycle without both a price ID and an
amount shows a disabled **Unavailable** button on your clients' billing page.
See [Price and bill your clients](/agency/plans-and-billing) for building plans and setting
their limits.
## Test it end to end
Prove the loop before a real client pays.
Most Stripe webhook tooling lets you send a test event to an endpoint. Send one (any
of the seven types) and check the response DMLY returns:
* **2xx**: the URL and signing secret are both right. You're wired up.
* **403 Invalid webhook signature**: the signing secret stored in DMLY doesn't match
this endpoint. Re-copy it and re-save.
* **404 Unknown agency**: the agency ID in the URL is wrong.
* **422 Agency Stripe webhook is not configured**: DMLY has no signing secret stored (or
no secret key), so the endpoint is off.
From a client workspace (a test sub-account works well; see
[Client workspaces](/agency/client-workspaces)), open **Plans & billing** and select
**Choose plan** on a priced plan. You should land on a Stripe-hosted checkout page
showing your plan's price. Complete the payment.
In your Stripe dashboard: the subscription exists, and each webhook delivery for it
shows a 2xx response with the body **Webhook Handled**. That body means DMLY
processed the event. An empty response body is still a 2xx, but it means the event
type isn't one DMLY handles. DMLY records the payment internally but doesn't
currently display it to you, so the Stripe delivery log is your confirmation. If
Stripe shows the money but the deliveries failed (or never happened), work through
the failure modes below.
Paying does not switch the client's plan by itself. After checkout, open
**Sub-accounts** and assign the plan the client bought; that is what applies the
plan's limits and updates the **Current** badge on their billing page. Make this part
of your routine whenever a client subscribes or changes plan.
A checkout against live keys charges the card for real, so if you test with your own card,
refund it afterwards from your Stripe dashboard. DMLY also accepts Stripe **test-mode**
keys (`sk_test_`), which run the whole flow in Stripe's test mode with test cards. If you
rehearse that way, remember keys, price IDs, webhook endpoint, and signing secret all
belong to one mode: switching to live means re-entering live keys, live price IDs, and a
live-mode webhook endpoint with its own signing secret.
## When something goes wrong
The nastiest one, because checkout itself works: the money is in your Stripe account, but
DMLY has no record of the subscription or payment. It means Stripe's events never reached
DMLY: the webhook endpoint was never created, points at the wrong URL, or doesn't
include the subscription events.
Diagnose it from your Stripe dashboard, in the webhook deliveries for your endpoint;
DMLY has no log of traffic that never arrived. No endpoint at all? Create it as above.
Deliveries listed but failing? Match the response code against the test step above.
Deliveries succeeding but nothing recorded? Check the event types. See below.
The client is not locked out while you sort this out: a client workspace's access follows
your agency's standing, not its own subscription state. Once the webhook is fixed, Stripe
can resend the missed events from the delivery history, and DMLY records each event only
once; resending is safe.
A signing secret is stored, but it isn't this endpoint's: usually the secret was copied from
a different endpoint, or the endpoint's secret was rolled in Stripe, or the secret belongs to
the other Stripe mode from the endpoint (test against live).
Fix: open the endpoint in Stripe, copy its current `whsec_` secret, and re-save the Stripe
panel in DMLY with the secret key and that signing secret both filled in.
A **422** rather than a 403 means no signing secret is stored at all. The
**Webhook signing secret** field is optional at save time, so you can finish the first
section, see **Connected**, and have every delivery refused. The panel flags that case in red
under **Your webhook endpoint**, telling you the endpoint is turned off until you paste the
secret.
DMLY returns a success response even for events it doesn't act on, so a wrongly-configured
endpoint can look healthy in Stripe while doing nothing. Start with the event types: open the
endpoint in Stripe and check the selected events against the list of seven above. In
particular, `customer.subscription.created` and `invoice.payment_succeeded` must be
selected, or no subscription or receipt is ever recorded.
If the event types are right, the event is being refused deliberately. DMLY checks three things
before acting on an agency webhook, and an event failing any of them is logged and answered
with `Webhook Skipped`, which Stripe records as a success:
* **The price must be one of yours.** An event naming a price id that isn't on one of your own
plans is skipped. This is the usual culprit: a Stripe Product created outside DMLY, or a plan
whose **Stripe price ID** was pasted with a typo.
* **The workspace must be yours.** An event naming a workspace that isn't one of your
sub-accounts is skipped, and so is one whose customer can't be traced to a workspace at all.
* **The price must not be the platform operator's.** An event referencing an operator price id
is always refused, whatever your own plans say.
These are fences rather than faults, so nothing in Stripe tells you which one fired. Check your
plans' Stripe price IDs first.
If the page flashes *This agency has not connected Stripe yet.*, no secret key is saved;
finish the first section above. If it flashes *That plan is not available.*, the plan
is disabled or the chosen cycle has no Stripe price ID; fix it on **Plans**.
If checkout fails with a generic server error, the saved secret key is wrong, or the key
and the plan's price IDs belong to different modes (a live key with test-mode `price_`
IDs, or the reverse). DMLY doesn't validate keys at save time, so this is where a bad key
first shows itself. Re-enter the key and confirm every price ID comes from the same
Stripe mode as the key.
Expected: payment and plan assignment are separate steps. Checkout records the
subscription and the payment; it does not change the workspace's plan. Assign the plan
from **Sub-accounts** after the payment lands. Until you do, the client's **Plans &
billing** page keeps the old **Current** badge and their limits stay as they were.
Also expected: a client workspace is never locked out automatically over its own failed
or cancelled payments; access follows your agency's standing with DMLY. Chasing payment
and suspending the workspace is your call, from **Sub-accounts**. See
[Client workspaces](/agency/client-workspaces).
## Related
Build the plans and add-ons you sell, and what clients see.
Assign plans, suspend, restore, and open a client's workspace.
What the whitelabel layer includes.
The other Stripe: how a workspace collects payments from its own customers.
# Connect your agency domain
Source: https://docs.dmly.io/agency/custom-domain
Serve your agency's DMLY on a domain you own, so your customers sign up and work under your address instead of a DMLY one.
The agency dashboard is only available once you have an agency, which comes from subscribing to a reseller plan. See [Agency overview](/agency/overview) and [Plans](/billing/plans).
Your custom domain is what makes the whitelabel real. Your [branding](/agency/branding), your app name, your colour and your logo, is applied by the address the page was opened on. Your public sign-up page only exists on your own domain: on the DMLY address it isn't there at all. So until a domain is connected, there's no address you can hand to a prospect.
You get one custom domain per agency.
This is not the [booking domain](/appointments/custom-domain). A booking domain belongs to a single workspace and serves that workspace's public booking page. An agency domain belongs to you, the agency, and serves the whole product: sign-up, login and the dashboard your customers work in. A host can only be one or the other; if you try to connect a domain that's already in use as a booking domain, you'll see **That domain is already in use.**
## Connect it
Open **Reseller Dashboard** from your user menu, then go to **Configuration → Domain**.
The panel shows the records to create under **Create one of these records at your DNS provider:** a **CNAME** or an **A record**. Add one of them at whoever manages your domain's DNS, your registrar or your web host. DMLY doesn't do this part for you.
Enter the host you want to use (for example `app.yourbrand.com`) and select **Add domain**.
Select **Verify**. If the record hasn't spread yet you'll get **DNS is not pointing here yet. Check the record and try again.** That's normal shortly after a DNS change. Leave it and try again later; DMLY also re-checks on its own roughly every ten minutes and verifies the domain as soon as it resolves.
Once it verifies, SSL is requested and issued for you automatically. You don't buy or install a certificate.
## What the status means
The panel shows one of three states:
* **Pending DNS**: the domain is saved but nothing points at it yet.
* **Verified · issuing SSL**: DNS resolves, and the certificate is being issued. This isn't instant.
* **SSL active**: DMLY has loaded your domain over HTTPS and confirmed the certificate is really live. The badge only turns green on a real end-to-end check, so it can't tell you the domain is ready when it isn't.
Wait for **SSL active** before you publish the address anywhere.
The background check runs in both directions, so a live domain isn't permanently live. If the DNS record is removed or stops resolving for around a day, the domain drops back to **Pending DNS** on its own. Keep the record in place for as long as you want the domain.
## Remove a domain
Select **Remove** on the domain row, then confirm with **Remove domain**. To move to a different host, remove the current one first; while a domain is connected the add field is replaced by **You’ve connected a custom domain. Remove it below to connect a different one.**
Removing the domain takes away the only address your public sign-up page lives on, and your existing customers lose the address they log in at. Have the replacement domain verified and live before you switch, and tell your customers.
## If it won't verify
DMLY can only verify a host that actually points at it, so the cause is almost always the DNS record. Re-check it against what the panel shows: the record type, the exact host, and that there isn't an older conflicting record for the same name still in place. Some DNS providers append your domain to the host automatically, which silently produces `app.youragency.com.youragency.com`. After a correction, select **Verify** again.
The host is claimed elsewhere, most often as a workspace [booking domain](/appointments/custom-domain), including in one of your own sub-accounts. Pick a different host, or free the existing one first. You also can't use the DMLY address itself.
Your app name, colour, logo and support email, applied on your domain.
The sub-accounts that arrive when customers sign up on your domain.
# Agency overview
Source: https://docs.dmly.io/agency/overview
How the DMLY agency layer lets you resell the platform under your own brand, domain, and Stripe account.
The agency layer turns DMLY into a product you sell. You run your own brand on your own
domain, your customers sign up with you, and you bill them through your own Stripe account.
DMLY stays invisible to them, and you stay the one they pay.
An **Agency** sits above workspaces. It owns a set of customer workspaces (called
**sub-accounts**) and carries your branding, one custom domain, your Stripe keys, your
mail server, and the plans you sell.
The agency layer only exists once you subscribe to a reseller plan. Everything on this page
assumes you have. See [Becoming an agency](#becoming-an-agency) below.
Prefer to watch first? This walkthrough shows how to launch your own WhatsApp CRM brand on DMLY with the agency layer.
## Who this is for
You want the agency layer if you sell WhatsApp messaging, chatbots, or booking as a service
and want your customers to see your name instead of DMLY. Typical fits: a marketing agency
packaging DMLY for local businesses, a consultant managing several clients, or a software
business bundling messaging into a wider offer.
You do **not** need it to manage more than one business yourself. If you just want a second
workspace, see [Workspaces](/getting-started/workspaces).
## Becoming an agency
There is no separate reseller application and no "become a reseller" page. You subscribe to a
reseller plan the same way you would any upgrade, and your agency is created for you.
Reseller plans are ordinary paid plans that DMLY has flagged as reseller plans. Nothing on
your billing page marks them as such, so ask DMLY which plan to pick.
Your agency is created automatically, named after the workspace you upgraded from, and
that workspace moves under it. Nothing else to fill in.
A **Reseller Dashboard** item appears in your user menu. That is the agency side of the
app, separate from your own workspace.
Which reseller plans exist, what they cost, and how many sub-accounts each allows depend on
the plan configuration. See [Plans](/billing/plans).
## What you get
App name, logo, support email, and a primary colour applied to every branded page.
One custom domain per agency, with SSL issued automatically once DNS points at us.
Build the plans you sell, set your own prices, and charge through your own Stripe account.
Assign plans, grant add-ons, suspend or restore access, and open any workspace directly.
## The Reseller Dashboard
Eight pages, reached from **Reseller Dashboard** in your user menu:
| Page | What it is for |
| - | - |
| **Overview** | Your agency at a glance, with recent sub-accounts. |
| **Sub-accounts** | Your customer workspaces: assign a plan, suspend or restore access, or open one. |
| **Plans** | The plans and add-ons you sell to your customers. |
| **Domain** | Connect and verify your custom domain. |
| **API** | Keys for the [Agency API](/api-reference/agency-introduction), which provisions workspaces and manages subscriptions programmatically. |
| **Webhooks** | Endpoints that receive [agency events](/api-reference/agency-webhooks) as they happen, signed so you can verify they came from DMLY. |
| **Settings** | Brand, Stripe keys, SMTP, signup settings, and custom scripts (owner only). |
| **Team** | Staff for your console, each with an access level. |
**Plans**, **Domain**, **API**, **Webhooks**, **Settings** and **Team** sit under a
**Configuration** heading. Staff see only the pages their access level allows.
## How customers reach you
There is no create button in the agency dashboard. Sub-accounts appear three ways in normal
use:
* Someone signs up on your branded domain. Your public signup page asks for their name,
email, password, and business name, and creates their workspace on your default plan. What
that page says is yours to write, under **Settings → Customer signup & workspaces**. See
[Client workspaces](/agency/client-workspaces).
* You provision one yourself over the [Agency API](/api-reference/agency-introduction), with
`POST /workspaces`. That single call creates the owner's login and their workspace under
your agency, on a plan you name or your first enabled plan, and returns the owner's name
and email.
* An existing customer of yours creates an additional workspace. It stays inside your agency
automatically. This only works if you set **Settings → Customer signup & workspaces → Allow
customers to create multiple workspaces** to **Yes**; it is **No** by default, and a
customer who already owns a workspace in your agency cannot create another.
The empty state on **Sub-accounts** points at the first of those: *No sub-accounts yet.
Customers who sign up on your domain appear here.*
So your first job after subscribing is [connecting your domain](/agency/custom-domain) and
[setting your brand](/agency/branding); until your signup page is live, nobody can sign
themselves up.
How many sub-accounts you can hold depends on your reseller plan. When you hit the cap,
signups on your domain close and the **Sub-accounts** page warns you. Contact support to
raise it.
## Who pays whom
Two separate money flows, and keeping them straight saves confusion later:
* **You pay DMLY** for your reseller plan, on DMLY billing, like any other customer.
* **Your customers pay you**, through the Stripe account you connect in **Settings → Stripe
(charge your customers)**. That money goes to you. DMLY takes no cut of it and never sees
the transaction.
You set your own prices on your own plans, so your margin is whatever you decide it is. If
you would rather not charge through Stripe at all, you can leave it unconnected and assign
plans to sub-accounts by hand.
Add-ons work the same way: when you grant a customer extra contacts, an extra profile per channel, or an
extra teammate, they get it immediately with no checkout; you are the one billing for it.
Your agency depends on your reseller subscription staying active. If it lapses or is
cancelled, your agency is suspended and **every one of your sub-accounts locks
immediately**. Your customers are sent to the standard workspace-locked page, and you see
*Your agency has been suspended. Please contact support.* when you try to open the Reseller
Dashboard. Resubscribing lifts it.
## Working inside a customer's workspace
On **Sub-accounts**, **Open** puts you inside that workspace with full access, as its owner.
Leave the same way you would leave any impersonation, from the user menu. Use it for setup
and support rather than asking a customer to hand over their password.
## What DMLY still controls
You run your brand, but you are a customer of DMLY underneath it, and a few things stay ours:
* **"Powered by"** appears on your public booking pages, on HTML invoices, and in
notification emails. On a branded agency it shows *your* app name, linking to your Powered
by link if you set one. There is no way to remove it entirely.
* **Sub-account access** follows your agency's standing. DMLY can suspend an agency, or
release one of your sub-accounts back to being a direct DMLY customer.
* **Everything a workspace does** (channels, inbox, automation, appointments, finance) is
the same product your customers would get directly. The agency layer changes who brands it
and who bills for it, not what it does.
## Next steps
App name, logo, colour, support email, and your own SMTP.
Point DNS, verify, and open your branded signup page.
Decide what you sell, at what price, with what limits.
Assign plans, grant add-ons, suspend, restore, open.
# Price and bill your clients
Source: https://docs.dmly.io/agency/plans-and-billing
Set up your own plans, charge clients through your own Stripe account, and understand what your reseller plan pays for.
There are two separate money flows in an agency, and they never touch each other:
Your own workspace carries a whitelabel reseller subscription, billed by DMLY on DMLY's
Stripe account. This is what makes you an agency. See [Plans](/billing/plans).
Your client workspaces are billed by **you**, on **your own Stripe account**, at whatever
price you set. DMLY never sees that money and takes no cut of it.
Your clients never see a DMLY price, and DMLY never charges them. You keep the difference
between what you charge and what your reseller plan costs.
## Connect your Stripe account
Nothing can be billed to a client until your own Stripe account is connected. Go to
**Settings → Stripe (charge your customers)**, enter your Stripe keys, and select
**Save Stripe keys**.
The page has three fields (**Secret key**, **Publishable key**, and **Webhook signing
secret**) and a badge reading **Connected** or **Not connected**. Your keys are stored
encrypted. Your secret key and webhook signing secret are never shown back to you; your
publishable key is.
Below the fields, the panel shows **Your webhook endpoint**, a URL you can copy. In your Stripe
Dashboard, create a webhook endpoint pointing at this URL, then paste its signing secret into
**Webhook signing secret** and save.
Without the webhook endpoint and its signing secret, a client's payment succeeds at Stripe but
no subscription is recorded in DMLY. If your account is connected but has no signing secret
saved, a red warning says the endpoint is turned off and rejects everything Stripe sends.
Charges, subscriptions, and receipts all run through your Stripe account, so refunds,
disputes, and payouts are handled in your Stripe dashboard, not in DMLY.
## Build your plans
Go to **Plans** in the agency dashboard and select **New plan**. A plan carries:
* **Enabled**: whether clients can subscribe to it.
* **Monthly** and **Yearly** prices.
* **Currency**, the label shown next to the prices on your plans list and your client's billing
page. It defaults to `USD`. The currency actually charged comes from the Stripe price you enter
in **Monthly Stripe price ID** and **Yearly Stripe price ID**, so keep the two matched by hand.
* **Trial days**: how long a client gets before the first charge. Leave it at `0` for no trial.
Anything higher is passed to Stripe Checkout as a trial period and shown on the client's own
billing page as an "N-day free trial", so it is a real offer, not a label.
* **Limits** (the caps a client workspace gets on that plan): **Contacts**, **Social
accounts**, **Team members**, **AI credits**, and **Active bots / channel**. Leave a limit
blank for unlimited, as the form notes with **(blank = unlimited)**.
**AI credits** does not cap the AI chatbot. It is an old limit for built-in content AI.
The chatbot is metered on AI replies, which this form cannot set, so a client on your plan
gets the system's default number of AI replies whatever you type here.
* **Features** *(off = not included in this plan)*: **AI Agent** and **Reputation &
Reviews**. Both start on, and only one of them still does anything.
* **Reputation & Reviews** is real. Switch it off and that client cannot manage Google
reviews, and an automation using a Google review-request step will not publish.
* **AI Agent** no longer has any effect. The switch is still on the form, but the AI agent
is on for every client whatever you set it to. Leaving it off does not take AI away.
A billing cycle is only offered to your clients when it has both a Stripe price and an
amount to display. If a cycle is missing either, the plan still appears on your client's
billing page, but with no price and a disabled **Unavailable** button instead of **Choose
plan**; the button and the price can never disagree.
When you assign a plan, its limits are copied onto the client workspace and enforced there
like any other DMLY plan.
A plan that is assigned to a client workspace, or granted as an add-on, cannot be deleted.
Move those clients to another plan first.
## Put a client on a plan
Open **Sub-accounts**, find the workspace, and pick a plan from the **— assign plan —**
dropdown. The client's limits change immediately.
Assigning a plan does not charge anyone. Billing starts when the client subscribes on their
own billing page. See below.
## Sell add-ons
An add-on is a plan you create with **Add-on plan** turned on, then choose an **Add-on type**:
* **Extra contacts**
* **Extra profile per channel**
* **Additional teammate**
Then set **Quantity granted (per unit)**: how much allowance one unit adds. It's required
for an add-on plan, and the form won't save without it.
Grant one from **Sub-accounts** with **+ grant add-on**, and take it back with the **×** on
the add-on chip. The extra allowance is added on top of the client's plan limits straight
away.
Add-ons you grant are billed by you, not by DMLY, and there is no checkout step; the
client gets the allowance the moment you grant it. Charge for it on your side.
An add-on plan you leave **Enabled** also appears on your clients' own **Plans & billing**
page as a plan they can subscribe to, and a priced one will start a real Stripe checkout.
Leave add-on plans disabled unless you intend that.
## What your clients see
Your clients don't have a DMLY billing page. When someone with admin access in a client
workspace opens **Billing** from their user menu, they land on **Plans & billing** (your
plans, your prices, your brand) and subscribe there through your Stripe account.
A **Monthly** / **Yearly** toggle sits above the plans, and appears only when at least one
plan is priced for each cycle. Their existing plan is marked **Current**.
Your own workspace is the exception. Its **Billing** still points at DMLY, because that's
where your reseller subscription lives. You are not a customer of your own agency.
## Your reseller limits
Your reseller plan caps how many workspaces you can have under your agency. The exact number
depends on the plan you're on. See [Plans](/billing/plans). Your own workspace counts
towards that cap: it sits under your agency too, so it appears in your **Sub-accounts** list
and takes one of the slots.
Your reseller plan also sets three pooled caps, shared by every workspace under your agency
(your own included): total contacts, total connected profiles, and total WhatsApp numbers.
A client can be refused a new contact or a new channel because of what other workspaces have
used, even with room left on its own plan and any add-on you gave it. The client then sees
"You've reached the contact limit for your account. Contact your provider to raise the limit."
or "You've reached the connected profile limit for your account. Disconnect a profile, or
contact your provider to raise the limit."
You never create a client workspace yourself; they arrive when someone signs up on your
branded domain or, if you've switched on **Allow customers to create multiple workspaces**
in Settings (off by default), when an existing client creates another workspace. See
[Client workspaces](/agency/client-workspaces).
When you're at your cap, signups on your branded domain stop and the **Sub-accounts** page
warns you: *You've reached your sub-account limit. Contact support to raise it.* Raising it
means moving to a bigger reseller plan, or asking DMLY support to lift it for your account.
If your reseller subscription lapses or is cancelled, your agency is suspended, and
**every one of your client workspaces is locked out immediately**, whatever plan they're
on and whatever they've paid you. Keep your own DMLY subscription current. Subscribing
again restores your agency and your clients.
## Related
Assign plans, suspend, restore, and open a client's workspace.
Your app name, logo, colour, and support email.
Serve your agency (and your signup page) on your own domain.
What your own reseller plan includes.
# Read the ads report
Source: https://docs.dmly.io/analytics/ads-report
See what each Click-to-WhatsApp ad produced in conversations, contacts and revenue, what it cost once your Meta ad account is connected, and how first-touch credit works.
**Ads** is a tab on **Reports**. It answers one question: of the people who tapped one of your ads and then messaged you on WhatsApp, what happened next. On screen it introduces itself as *What each ad produced in conversations, contacts and revenue.*
It only reads. Nothing here changes a contact, a conversation or a payment.
The page has two halves, and they come from different places:
* **Responses** (conversations, messages, new contacts, revenue) come from the messages people send you after tapping an ad. You get these on every workspace, with nothing to set up.
* **Spend** (what each ad cost, and the cost per conversation) comes from your own Meta ad account. You only get it once you [connect your Meta ad account](/broadcasts/marketing-messages#connect-your-meta-ad-account) in DMLY, and connecting one depends on your [plan](/billing/plans). Without it, every spend cell is a dash.
The page says this under the cards, in these words:
> Responses come from people who clicked an ad and messaged you. Spend and delivery come from your connected ad account, so an ad can appear here with spend and no conversations. A dash means we have no synced figure for that ad, not a zero.
Read [What DMLY sees, and what it cannot](#what-dmly-sees-and-what-it-cannot) before you put a revenue figure next to an ad and call it profit.
## Open the report
In the sidebar, under **Configurations**, select **Reports**, then select the **Ads** tab. It sits between **Finance** and **CSAT**. **Finance** only appears if your role can view finance, so on some accounts the strip is five tabs and **Ads** is third.
**Ads with replies**, **Conversations**, **New contacts** and **Revenue**, plus **Ad spend** once your ad account has synced a figure for your dates. **Revenue** and **Ad spend** are there only if your role can view finance; without it you get the other three.
One row per ad, busiest first. If your role can view finance, the last two columns are **Ad spend** and **Cost per conversation**. They show a dash until your ad account has synced a figure for that ad.
Set **From** and **To** to narrow the window. Leave both blank and the report covers the trailing 30 days. Some figures ignore your dates on purpose; see [Filter by date](#filter-by-date).
**CSV** gives you the table as a spreadsheet, **PDF** the same report as a document. See [Export](#export).
The responses on this report come from ads that open a **WhatsApp** chat, which is what the browser tab calls them: **Click-to-WhatsApp ads**. Messages from an ad that opens a Messenger or Instagram chat are not counted here. The ad's details reach DMLY on WhatsApp's own webhook, and there is no equivalent report for the other two.
Spend is different. It is read from the whole ad account you connected, so any ad in that account that spent money in your dates gets a row, even one that never sent anybody to WhatsApp. It shows its spend and no conversations.
## What DMLY sees, and what it cannot
| DMLY tells you, on every workspace | DMLY adds, once your ad account is connected | Only Meta Ads Manager tells you |
| - | - | - |
| How many people tapped the ad and actually messaged you | What each ad cost in your dates | How many people saw the ad, and how many tapped without messaging |
| How many of those people were new to your business | What each conversation cost: the ad's spend divided by its conversations | Cost per click, reach and frequency |
| How much money those people have since paid you | Impressions and clicks per ad, in the CSV only | Your return on ad spend |
DMLY puts revenue and spend side by side, but it never divides one by the other, on the page or in the exports. To work out whether an ad paid for itself, set its **Revenue** against its **Ad spend** yourself, and remember the two can be in different currencies (see [Caveats](#caveats)).
Spend is synced from Meta every hour. Meta can restate an ad's figures for up to 28 days, so the last few weeks can still move a little after you first read them.
**An ad missing from the list is not an ad with a zero.** Without a connected ad account, an ad only appears once somebody has tapped it and messaged you inside your date range. A campaign that ran all week and got no messages is simply absent. With an ad account connected, an ad that spent money in your dates appears too, even with no messages. Either way, an ad that neither spent nor got a message in your dates has no row.
## What each number means
The cards sum up the whole window.
| Card | What it counts |
| - | - |
| **Ads with replies** | The number of rows in the table. Without an ad account connected, that is how many different ads got at least one message in the window. With one connected it also counts ads that spent money and got no replies, so despite its name it can be higher than the number of ads anybody answered. |
| **Conversations** | Every row's **Conversations** added together. |
| **New contacts** | Every row's **New contacts** added together. |
| **Revenue** | Every row's **Revenue** added together, in your workspace currency. Shown only if your role can view finance. |
| **Ad spend** | Every row's **Ad spend** added together, in your ad account's currency. Ads with a dash are left out of the sum rather than counted as zero. Shown only if your role can view finance, and only once there is a synced figure for your dates. |
The table breaks the same figures down per ad.
| Column | What it counts |
| - | - |
| **Ad** | The ad's headline in bold, or **Untitled ad** when the ad has none. Underneath, in small type: **From an ad** or **From a post**, then the ad's ID from Meta, for example `120209428414010371`. |
| **Conversations** | **People**, not threads and not messages. Somebody who sends you four messages off one ad is one conversation. |
| **Messages** | Every message that arrived carrying that ad, in the window. |
| **New contacts** | The contacts this ad brought to your business for the first time. See [Who gets credit for a contact](#who-gets-credit-for-a-contact). |
| **Revenue** | Money collected from that ad's contacts inside the window. Shown only if your role can view finance. See [Revenue](#revenue). |
| **Ad spend** | What Meta charged for that ad inside the window, in your ad account's currency. A dash means there is no synced figure: no ad account is connected, the ad belongs to a different ad account, the row is a post rather than an ad, or the first sync has not run yet. A dash is never a zero. Shown only if your role can view finance. |
| **Cost per conversation** | **Ad spend** divided by **Conversations**. A dash when there is no spend figure, or when the ad had no conversations. Shown only if your role can view finance. |
Rows are ordered busiest first: most conversations, then most messages, then most spend. So among ads that got no messages, the one that cost you most comes first.
Two ads can share a headline, but never an ID. When you need to be certain which ad a row is, go by the ID under the headline, not by the words. If you edit an ad's headline mid-campaign the row shows whatever the ad says now, not what it said when the first person tapped it.
## Who gets credit for a contact
**A contact belongs to exactly one ad: the first one that ever brought them to you.** That is first-touch credit, and it never moves afterwards.
This matters most for the ads you run at people who already know you. Say a salon runs a **Summer Sale** ad in June that brings in forty new clients, then a **Winter Clearance** ad in December aimed at reminding old clients you exist. Fifteen of those June clients tap the December ad and message you.
| What the December ad gets | What the June ad keeps |
| - | - |
| Fifteen **Conversations**, because it genuinely produced them | The fifteen **New contacts**, because it is what won them |
| No **New contacts** for those fifteen | Their **Revenue**, wherever they spend it later |
If it worked the other way round, every re-engagement ad you ever ran would look like your best source of new business, and the ad that actually paid to win those clients would show nothing. So a re-engagement ad is credited honestly: it gets the conversations it caused, and it does not claim customers it merely reached again.
**New contacts counts an ad's whole history, not just your date range.** The dates decide which ads appear in the list, and what their **Conversations** and **Messages** figures are. **New contacts** is then the full count of everyone that ad has ever acquired. Narrowing the dates will not narrow that column.
The same rule writes the **Source** on the contact record itself, in words rather than IDs: `WhatsApp ad: Summer Sale`, or `WhatsApp post: Our new opening hours` when the tap came from an organic post rather than a paid ad. A later ad never overwrites it, and neither does anything overwrite a source you typed in yourself, so a contact you had already marked as `Walk-in referral` keeps that. You can filter and segment on **Source**; see [Tags and segments](/contacts/tags-and-segments).
## Revenue
Revenue against an ad is money you collected inside your date range, from the contacts that ad acquired.
It values a payment almost the same way the [finance report](/analytics/finance-report) does for
**Money in**:
* Dated by the day the payment arrived, not by the day the ad ran and not by the day you invoiced.
* Tips are excluded. A gratuity moves money but is not takings.
* Refunds are taken off. One difference: **Money in** never takes off more than the payment's tip-free amount, but this report takes off the full refund. So when a refund includes a tip, the ad's revenue comes out lower than **Money in**, and can go below zero.
* A payment that never actually arrived does not count.
* Amounts are added up in your workspace currency, with no conversion between currencies.
One kind of payment lands here that **Money in** deliberately leaves out: a
[gift card](/offerings/gift-cards) being spent. The finance report drops those, because it already
counted that money when you sold the card; this column does not. So an ad-won customer paying with
a gift card adds to their ad's revenue without adding to **Money in**. Either way these rows are a
slice of your takings rather than all of them, and they are not meant to add up to **Money in**.
Because it is dated by the payment, an ad from six months ago can show revenue this month, as long as somebody also tapped that ad inside your window. If nobody did, the ad has no row at all and that money appears nowhere on this page.
Revenue beside an ad is what those customers paid you. It has nothing subtracted for what the ad cost, even when **Ad spend** sits in the next column. It is not profit, and it is not return on ad spend.
### Who can see the money
The **Ads** tab itself is not restricted to people who can see finance. Anyone who can open **Reports** gets it.
* **Can view finance** (owners and admins, and by default the built-in Member and Viewer roles too; only a custom role without **View finance (invoices, payments, orders)** loses it): the full report, with the **Revenue** and **Ad spend** cards, the **Revenue**, **Ad spend** and **Cost per conversation** columns, and the money in both exports.
* **Cannot view finance**: the same report with the money taken out, both what you earned and what you spent. Three cards, no money columns, and no revenue or spend in the CSV, the PDF or the ads page of the workspace report. An ad that spent money but got no messages still has a row, reading zero conversations, because the spend that put it there is withheld. Everything else works normally: conversations, messages, new contacts, the dates and the exports are all there.
The money is not merely hidden from the screen for that teammate. It is removed before the page is built, so it is not sitting behind the table waiting to be found. See [Roles and permissions](/account/roles-and-permissions).
Exporting is a separate permission again: **CSV**, **PDF** and **Workspace report** all need **Export reports**, which is admin only by default. The buttons are shown to everyone, so a teammate can select one and still be turned away. That is true of every Reports tab.
## Filter by date
**From** and **To** are the only filters, and there is nothing to select to apply them: the report reloads the moment you change a date. A **Clear filter** link appears while either is set and resets both.
* Leave both blank and you get the trailing 30 days, ending today.
* Set **From** later than **To** and DMLY reads it as a typo and swaps them, rather than showing you nothing.
What your dates govern:
| Moves with the dates | Ignores the dates |
| - | - |
| Which ads appear at all | **New contacts**, which counts each ad's whole history |
| **Conversations** and **Messages** | |
| **Revenue**, by the day each payment arrived | |
| **Ad spend** and **Cost per conversation**, by the day Meta spent it | |
With nothing to show, the table reads **No ad-referred conversations in this range.**
On a very busy range a warning appears above the table: **Showing the first 20,000 ad messages in this range. Narrow the dates for exact figures.** Split the range into shorter windows when you need the counts to be exact.
## Export
`click-to-whatsapp-ads.csv`: one row per ad, in the same order as the table, with the columns **Ad ID**, **Headline**, **Type**, **Conversations**, **Messages**, **New contacts**, **Impressions** and **Clicks**. When your role can view finance it adds **Revenue**, **Currency**, **Spend**, **Spend currency** and **Cost per conversation**.
The file splits the table's single **Ad** cell into three columns, and **Type** carries Meta's own raw word, `ad` or `post`, rather than the **From an ad** wording you see on screen. **Impressions** and **Clicks** are in the file only; the page does not show them. **Currency** is your workspace's, for **Revenue**; **Spend currency** is your ad account's, for **Spend**. Where there is no synced figure, **Impressions**, **Clicks**, **Spend** and **Cost per conversation** are empty cells rather than 0, so a spreadsheet total never counts a figure nobody measured.
`click-to-whatsapp-ads.pdf`: the report as a document, laid out like every other Reports PDF, with your business header at the top and the date range under the title.
Inside it: the same cards (**Ads with replies**, **Conversations**, **New contacts**, and **Revenue** when your role can view finance, plus **Ad spend** when there is also a synced figure), a **Busiest ads** bar chart of the top eight by conversations, and an **Every ad** table listing all of them. The chart only appears when the table has more than one row, and when there are more than eight a line under it names the total, so you know the table is the longer list.
The **Every ad** table has no spend or cost per conversation columns. In the PDF, spend appears only as the **Ad spend** total. Use the CSV when you need spend ad by ad.
The document repeats the caveats rather than leaving them on screen: where responses come from, and a message-limit warning on a busy range. When your role can view finance it also says where spend comes from, and adds the currency note and a stalled-sync line when they apply. With nothing in the range it prints **No ad-referred conversations in this range.**
The combined PDF carries an **Ads** section, on its own page after the others. It appears only when the ads table for that document's trailing 30 days has at least one row: an ad that got a message, or, with an ad account connected, an ad that spent money. With neither in those 30 days, the report comes with no ads page at all rather than an empty one.
That condition is about content, not permission. The finance section turns on whether your role can view finance; the ads section turns on whether there is anything to report. When the ads section does appear, everyone gets it, with the **Revenue** column and the **Ad spend** total left out for a role that cannot view finance.
Your **From** and **To** carry into the CSV and the PDF, so both files match the window you were looking at. The **Workspace report** carries nothing across and always covers the trailing 30 days, and its ads page renders only if there is something in those 30 days; this tab's own **PDF** renders whether or not there is. See [Exporting reports](/analytics/exporting-reports).
## Where else an ad shows up
An ad-referred WhatsApp message carries a small card above it reading **From an ad** or **From a post**, with the ad's headline, the first lines of its copy and its picture. Where the ad has a link the card opens it. So whoever answers can see which offer the person is replying to before typing a word.
The contact's **Source** names the ad that brought them in: `WhatsApp ad: Summer Sale`. See [Contacts](/contacts/overview).
The **Click-to-WhatsApp ad** trigger starts a flow when somebody messages you from an ad: any ad, or one specific ad. Copy the ID out of this report's **Ad** column to pin a flow to one. See [Triggers](/automation/triggers).
## Caveats
Nobody has tapped it and messaged you inside your dates, and DMLY has no spend for it in those dates either. Without a connected ad account, DMLY learns about an ad only from the message it produces, so an ad with no messages is invisible here rather than a row of zeros. Connect your ad account and an ad that spent money shows up even with no messages. Otherwise widen the dates, then check the ad in Ads Manager to see whether it is getting clicks at all.
Look at the line under the cards. It tells you which of these it is:
* **No line at all:** usually no ad account is connected, so there is nothing to sync. See [Connect your Meta ad account](/broadcasts/marketing-messages#connect-your-meta-ad-account). If one is connected, it has synced and simply has no spend for these ads in your dates.
* **An ad account is connected. Spend and delivery appear after the first hourly sync.** Wait for the hour to turn.
* **Several ad accounts were shared and none is chosen yet. Pick one under Integrations, Meta Ads, before spend can be reported.** DMLY never guesses which account is yours. Open **Integrations → Meta Ads** and choose one.
* **Meta Ads is not included in your plan. Upgrade to connect an ad account.** An ad account is still connected, but your plan no longer includes Meta Ads, so syncing has stopped and any figures are from before it did. See [Plans](/billing/plans).
* A red line starting **Spend stopped syncing** or **Spend is not syncing**: Meta stopped accepting the connection. Open **Integrations → Meta Ads**, disconnect and connect again. Until you do, the figures are from the last sync that worked.
With everything working, a dash on one row still means that ad has no figure: it is a post rather than an ad, or it belongs to a different ad account from the one you connected.
Revenue is in your workspace's currency and spend is in your ad account's. When the two differ, the page says: **Your ad account bills in a different currency from this workspace, so return on ad spend is not shown.** DMLY does not convert between them, so do that sum yourself with the rate you use for your books.
On the broadcast itself, not here. A paid Marketing Messages broadcast gets its own **Paid campaign** panel with Meta's figures for that send, including its spend if your role can view finance. See [What it costs](/broadcasts/marketing-messages#what-it-costs).
Correct, and deliberate. The dates decide which ads are listed, and how many conversations and messages they had. **New contacts** is the total that ad has ever acquired, so it stays put. To ask "how many new people did this ad bring me in March", set the dates to March and read **Conversations**, then check the contacts themselves by **Source**.
That is the report working. They were already yours, so the ad gets the **Conversations** it earned while the credit for winning them stays with the ad that first brought them in. Anything they spend stays with that first ad too.
The two are not measuring the same thing. This column is money you actually collected in DMLY from the people an ad brought you: after refunds, without tips, and with nothing subtracted for ad spend. Meta reports on what it observed on its own platform. Expect them to differ. **Ad spend** on this page is Meta's own figure, synced from your ad account, so for cost the two should broadly agree, allowing for the hourly sync and Meta restating recent days.
Their role cannot view finance. They still get the rest of the report. Give them **View finance (invoices, payments, orders)** if they should see the money. See [Roles and permissions](/account/roles-and-permissions).
Check the IDs under the headlines. Two different ads sharing a headline get a row each, which is right: they are two ads. One ad never splits across two rows, however often you rename it.
## Next
Where the revenue rules come from: what counts as money in, and what does not.
Start a flow when somebody messages you from an ad.
Contact records, and the **Source** that names the ad.
Every CSV and PDF export across Reports, in one place.
# Conversation reports
Source: https://docs.dmly.io/analytics/conversation-reports
Read the inbound and outbound message numbers on your Overview page, and understand what each one counts.
**Overview**, the first page you land on, is where conversation volume is reported: the
**Message volume** chart and the **Reply rate** card. It is not the only reporting screen.
**Configurations → Reports** is a section of its own, with six tabs (**Team**,
**Appointments**, **Finance**, **Ads**, **CSAT** and **Logs**), each with its own filters and
its own export buttons. **Ads** is the newest of them: what each Click-to-WhatsApp ad brought
back in conversations, contacts and revenue, and what it cost once your Meta ad account is
connected. See [Ads report](/analytics/ads-report) and
[Finance report](/analytics/finance-report).
Overview carries no per-agent breakdown, but DMLY does: the **Team** tab of Reports is exactly
that, teammate by teammate. See [Team performance](/analytics/team-performance). For numbers
about a single automation, open the ⋮ menu on its row in the Automation list and select
**Analytics**. See [Automation overview](/automation/overview).
What no screen gives you is a per-conversation report: nothing lists conversations with their
own numbers. The closest thing is [Logs](/troubleshooting/logs), the last Reports tab, and
that is an event feed rather than a report.
## What is measured
A two-line chart headed **Message volume**, described as *Outbound vs inbound messages over
the last 14 days*. One line is Outbound, the other Inbound. If neither side has any activity
in the window, the panel shows **No message activity yet.**
A card in the **At a glance** grid. It is the share of your contacts who have ever sent you
an inbound message, out of all your contacts.
A card in the same grid: an all-time count of messages sent for the selected channel.
Two extra cards that appear only when the selected channel is WhatsApp and that channel has
call history or has calling switched on. Otherwise they are hidden rather than showing zeros.
A number with calling on but no calls yet does show them, reading 0 and 0%. See
[WhatsApp](/channels/whatsapp).
The **At a glance** grid also carries **Contacts**, **Flow completion**, **Broadcast delivery**,
**Growth clicks**, **Opt-out rate** and **Active sequences**. Further down the same page,
**Peak conversation hours** plots inbound messages by weekday and hour over the last 28 days.
Those are covered on [Analytics overview](/analytics/overview).
## Filters and date ranges on Overview
This is the part people most often get wrong, so read it before you draw conclusions.
* **The only filter is the channel selector.** Pick a channel at the top of the page and the
conversation numbers follow it. There is nothing else to narrow by: no agent, no tag, no
segment.
* **There is no date-range picker.** You cannot change the period, and you cannot compare two
periods.
* **The chart window is fixed at the last 14 days.** It is not adjustable.
* **Every other number is all-time.** **Reply rate**, **Messages sent** and the rest count
everything since the workspace started, not the last 14 days.
All four of those are true of Overview only. If you need a period you can set, the Reports tabs
take one: **CSAT**, **Team**, **Finance** and **Ads** have **From** and **To** (defaulting to the
trailing 30 days), **Appointments** has the same pair plus staff, service and status filters, and
**Logs** has a **Date range** select with 24-hour, 7-day and 30-day presets.
**Finance** keeps three things outside its own dates on purpose: the **Outstanding** tile, the
**What is still owed** table, and the past-due line printed beneath that table. Those are what
your clients owe you right now rather than for a period, so moving **From** and **To** does not
move them. Everything else on the tab does. See [Finance report](/analytics/finance-report).
**Ads** has a similar exception. Its **Conversations** and **Messages** columns follow the dates
you set, but **New contacts** counts every contact that ad has ever won you, whatever window you
are looking at. See [Ads report](/analytics/ads-report).
Because the chart is 14 days and the cards are all-time, the two never add up. A quiet
fortnight will show a flat **Message volume** chart while **Messages sent** still reads high.
That is expected, not a bug.
## How to read them
**Reply rate** answers "how many of my contacts have ever replied to me?", not "what share of
my messages got a reply?" and not "how fast did my team answer?". A contact who replied once,
two years ago, counts the same as one who replies daily. It moves slowly and it only ever
moves up unless you add contacts who never reply.
This makes it useful for one thing: spotting a contact list that has gone cold or was imported
from a source that never engages. It is not a service-level measure.
Switch channels and the chart redraws. If you run WhatsApp and Instagram, neither view shows a
combined total; check each one. A completely flat chart on a channel you know is busy is a
connection symptom, not a reporting one; see
[No inbound messages](/channels/no-inbound-messages).
The **Recent activity** panel lists the last handful of events on the selected channel with a
status dot and a time. It is there for a quick "did that just work?" check. For anything you
need to investigate properly, use [Logs](/troubleshooting/logs).
## What Overview will not give you
* **No CSAT scores on Overview.** No card here counts survey ratings. They are totalled and
charted on the **CSAT** tab of Reports instead: response rate, average score, satisfaction, a
score distribution, a daily trend and the most recent responses. See [CSAT](/analytics/csat).
* **No money on Overview.** No **At a glance** card totals what you took, what you billed or
what you are owed. Those live on the **Finance** tab of Reports. See
[Finance report](/analytics/finance-report).
* **No ad numbers on Overview.** Nothing here tells you which Click-to-WhatsApp ad a
conversation came from. The **Ads** tab of Reports does, ad by ad. See
[Ads report](/analytics/ads-report).
* **No export.** Overview has no export button. Every Reports tab has export buttons, and so
do Contacts and your product catalogue. See
[Exporting reports](/analytics/exporting-reports).
* **No scheduled or emailed reports.** Nothing arrives on a timer; every export is one you ask
for.
Reporting is not restricted by plan. What you see is set by your role: a team member without
Appointments access simply loses the appointments panel, and the whole **Reports** section is
hidden from a role with no reports permission at all. The **Finance** tab asks for one
permission more than the rest: a role that cannot view Finance sees five tabs instead of six.
**Ads** stays on the strip for that role; it just loses its money, both revenue and ad spend.
Exporting from Reports needs the separate **Export reports** permission. See
[Roles and permissions](/account/roles-and-permissions).
Every card on the Overview page and what it counts.
Collect ratings in a flow, route by score, and read the CSAT report.
The per-agent breakdown: assignments, replies and response times.
Money in, billed, outstanding and spent, on the Finance tab of Reports.
What each Click-to-WhatsApp ad returned: conversations, contacts and revenue.
What can leave DMLY as a CSV, and what cannot.
Where the conversations these numbers describe actually happen.
# Collect customer satisfaction scores
Source: https://docs.dmly.io/analytics/csat
Ask contacts to rate your service in WhatsApp, route each score to a different follow-up, send happy responders to a Google review, and read the results on the CSAT report.
CSAT ("customer satisfaction") is a rating you ask a contact for at the end of a conversation. In DMLY it is a step inside an automation: the **CSAT Survey** node sends the question, the contact taps a rating, and the flow continues down a different branch for each score.
Every survey your automations send is recorded and reported on the **CSAT report**: response rate, average score, a score breakdown, a trend chart, and every individual response, filterable by date and exportable as CSV or PDF. See [Read the results](#read-the-results) below. One exception: a reply to the out-of-window `dmly_csat` template is not scored, so that survey stays **Awaiting reply**. See [Surveys sent outside the 24-hour window](#surveys-sent-outside-the-24-hour-window). Design the survey itself around **what happens next** (escalate unhappy contacts, ask happy ones for a review): the branching below is still what actually reacts to a score the moment it arrives.
## Ask for a score
Add a **CSAT Survey** node to a flow in the [flow builder](/automation/flow-builder).
A survey lands best after the work is done: after a support reply, after an appointment, after an order. Put a delay in front of it so you are not interrupting the conversation you are asking about.
The default is **How would you rate our support today?**. Rewrite it in your own words: one short question, about one thing.
Ratings are shown as stars. The lowest option is labelled **Poor** and the highest **Excellent** by default, and you can change both.
A 3-point survey arrives as tappable buttons, which is the fastest thing to answer. A 5-point survey arrives as a list the contact opens and picks from: more granular, one extra tap.
Every score gets its own output port, so you decide branch by branch what a 1 means and what a 5 means. The builder's own advice: **Each score is its own port — route detractors straight to a human.**
The score is recorded on the CSAT report as soon as the contact taps a rating in the survey, wired or not. The out-of-window `dmly_csat` template is the exception: its replies are not scored. An unrouted port loses the follow-up, not the response: nothing happens next for that score.
## Send happy responders to a Google review
The strongest use of CSAT is turning a good score into a public review while the goodwill is fresh.
DMLY ships this as a ready-made automation template, **Post-chat CSAT → Google review**. Start from it rather than building the pattern by hand:
* An agent sends an outgoing message (the conversation is over).
* An hour passes.
* The **CSAT Survey** node asks for a 5-point rating.
* Happy scores get a short thank-you, then a **Request Google review** node.
* Unhappy scores are assigned to a person instead, so a human picks the conversation up.
The **Request Google review** node sends a tracked WhatsApp message pointing at one of your connected Google Business Profile locations, so you need a profile connected first. It never ends the conversation, whatever the outcome. See [Review requests](/reputation/review-requests) and [Reputation](/reputation/overview).
Only route genuinely happy scores to a review request. Asking every responder, including the ones who just told you they are unhappy, is how you collect one-star reviews.
## Read the results
In the sidebar, under **Configurations**, select **Reports**, then select the **CSAT** tab. Reports opens on **Team**, so CSAT is one click further. This is DMLY's dashboard for every survey your automations have sent. Surveys only go out on WhatsApp, because the **CSAT Survey** node is only offered in WhatsApp automations.
**Configurations → Reports**, then select the **CSAT** tab. **Team**, **Appointments**, **Finance**, **Ads** and **Logs** are the others, and CSAT sits fifth of the six, between **Ads** and **Logs**. **Finance** appears only if your role can view Finance, so on some accounts the strip is five tabs rather than six.
Five cards summarise the window: **Response rate**, **Average score**, **Satisfaction**, **Answered** and **Awaiting reply**.
Set **From** and **To** to narrow the window. Leave both blank and the report defaults to the trailing 30 days.
**CSV** gives you every survey in the window: sent time, answered time, contact, status, score, scale and question. **PDF** downloads a formatted CSAT report; **Workspace report** downloads one PDF covering CSAT, Team performance and Appointments together, plus a [Finance](/analytics/finance-report) section when your role can view Finance and an [Ads](/analytics/ads-report) section when there is an ad to report: one that got a message, or, with an ad account connected, one that spent money. **Logs** is never in it, and it always covers the trailing 30 days rather than the window you set here. See [Exporting reports](/analytics/exporting-reports).
### What the numbers mean
**Response rate is `answered ÷ (sent + answered)`.** A survey that failed to send (a permanent delivery failure) is excluded from that total and reported separately: the page shows *"N survey(s) failed to send and are excluded from the response rate"* beneath the cards whenever any exist. A survey still waiting on a reply counts as **sent**, not as a failure; it just hasn't resolved yet.
| Card | What it counts |
| - | - |
| **Response rate** | `answered ÷ (sent + answered)`, as a percentage. Failed sends are excluded. See the note above. |
| **Average score** | The mean score across every *answered* survey in the window. |
| **Satisfaction** | The share of answered surveys landing on the top two points of their own scale (score at or above scale − 1): a 4 or 5 on a 5-point survey, or a 2 or 3 on a 3-point one. |
| **Answered** | Surveys with a recorded score. |
| **Awaiting reply** | Surveys that sent successfully and are still waiting on the contact. |
Below the cards, **Score distribution** bar-charts every answered survey by score, and **Responses over time** lines up the daily count of answered surveys across the window. **Recent responses** lists the last ten answers with the contact, score, question and when they answered.
The CSAT report is workspace-wide; unlike Overview, it is not filtered by the channel selector. Surveys from all your WhatsApp numbers, 5-point and 3-point alike, count toward the same numbers.
### Beyond the dashboard
For one automation's own delivery detail (runs, messages sent, last run, a paginated activity feed of what it sent), open **Analytics** on the automation itself. See [Automation overview](/automation/overview). **Flow outcomes** on [Overview](/analytics/overview) shows where survey-carrying runs end up; a survey still waiting on a reply sits in the **Waiting** group there.
Want a score on the contact record itself, to filter or segment by it? Add a **Tag Action** node on each score's path before the branch continues, e.g. a `csat-1` … `csat-5` tag. The CSAT report answers "how are we doing"; a tag answers "which contacts said what", filterable in [Contacts](/contacts/overview).
## Surveys sent outside the 24-hour window
WhatsApp only lets you send a freeform message within 24 hours of the contact's last message. Past that, you need an approved template. See [Message templates](/broadcasts/message-templates) and [WhatsApp limits](/channels/whatsapp-limits).
A **5-point** survey falls back to the built-in `dmly_csat` template, which must be approved by Meta for your workspace before it can send. If it is not approved, the node tries the normal send and records that the window was closed.
A **3-point** survey has no matching template, so it can only send inside the window.
If your surveys stop arriving for older conversations, that fallback template is the first thing to check.
Replies to the `dmly_csat` template do not work like the in-window survey. Its buttons send back text such as "5 - Excellent", which matches none of the score branches, so no score branch runs and the score is not recorded. The survey stays **Awaiting reply** on the report.
Usually timing. A survey that arrives mid-conversation gets ignored; one that arrives days later gets ignored too. Put a short delay after the last message, keep the question to one line, and prefer the 3-point scale; buttons are answered more often than a list.
If nothing at all is sending, start at [Automation not triggering](/troubleshooting/automation-not-triggering).
## Next
Another tab on Reports: assignments, replies and response times per teammate.
The money tab on Reports: what came in, what was billed and what is still owed.
The tab before CSAT: what each Click-to-WhatsApp ad returned.
Every export across Reports, in one place.
# Export your data
Source: https://docs.dmly.io/analytics/exporting-reports
What you can export from DMLY, in what format, and how to get everything else through the API.
Several things export your data from the app, straight to your computer: your **Contacts** and
your product catalogue as CSV, individual **invoices** as PDF, and the six **Reports**
(Team performance, Appointments, Finance, Ads, CSAT and Logs) as CSV and as a formatted PDF,
plus a combined **Workspace report** PDF covering up to five of the six at once: CSAT, Team
performance and Appointments always, Finance when your role can view Finance, and Ads when the
window has an ad to report. Logs is the only tab never in it.
Automation flows also export as `.json`. There is no Excel export, and no scheduled or emailed
report; anything beyond those comes out of the [REST API](/api-reference/introduction).
Exports are not gated by your plan. What you can export depends on your role and permissions.
See [Roles and permissions](/account/roles-and-permissions).
Your contact list as `salesbot-contacts.csv`.
Your product catalogue as `salesbot-products.csv`.
Survey responses as CSV or a formatted PDF.
Per-agent activity as CSV or a formatted PDF.
Every payment you collected as CSV, or a formatted PDF.
One row per Click-to-WhatsApp ad as `click-to-whatsapp-ads.csv`, or a formatted PDF.
## Export your contacts
The contacts export gives you every contact in the workspace, not just the ones who have
messaged on the channel you're currently viewing. Contacts belong to the workspace, not to a
channel.
The file is `salesbot-contacts.csv` with these columns:
| Column | What's in it |
| - | - |
| `name` | The contact's name |
| `country_code` | Always blank. See the note below |
| `phone` | The contact's phone number |
| `tags` | All tags, separated by `\|` |
| `stage` | The sales pipeline stage the contact sits in (matched back by name on import) |
| `notes` | All notes, separated by `\|` |
| `custom_fields` | Custom field values, as JSON |
The `country_code` column is always empty, on every row. It exists so the export matches the
layout the contacts importer expects; the country code is already part of `phone`. Don't
build anything that reads it.
The file is written for round-tripping: you can edit it and feed it back through the contacts
importer. See [Contacts overview](/contacts/overview) for how importing handles duplicates.
To export, open **Contacts** and select **Export** in the toolbar at the top, between **Client letters** and **Import**. You only see it if your role can export contacts, which by default means owners and admins.
The channel you have selected on screen is not applied. The file holds every contact you are allowed to see: the whole workspace for an admin, or only your own channels' contacts if your access is limited to certain channels.
## Export your products
The products export is the whole catalogue, every time; it has no filters and ignores whatever
you're looking at on screen. Products come out in the order they're sorted in the catalogue.
The file is `salesbot-products.csv` and covers `name`, `sku`, `price`, `cost_price`,
`currency`, `tax_rate`, `tax_inclusive`, `track_stock`, `stock_quantity`,
`low_stock_threshold`, `category`, `active` and `description`.
Services and Plans do not export. Only products do.
To export, open **Offerings**, go to the **Products** tab, and select **Export** in the row of buttons at the top (with **Import** and **AI Catalogue Builder**).
## Export your reports
**Configurations → Reports** holds six tabs (**Team**, **Appointments**, **Finance**, **Ads**,
**CSAT** and **Logs**) and every one of them exports the same way, from the same three buttons in
its top-right corner:
The report's underlying rows: every CSAT survey, one row per agent's totals, every
individual appointment booking, every payment you collected in the window, one row per ad, or
every log entry for the selected channel matching the current filters and date range (capped
at the most recent 5,000 log rows). Workspace-level log entries such as invoices, appointments
and webhook deliveries are left out of the Logs CSV and the Logs PDF, even though the list on
screen shows them; see [Reading the Logs page](/troubleshooting/logs#exporting). Not the
Appointments tab's **By service** and **By staff** breakdown tables; those are summaries, not
row data, so they only appear in the PDF.
The Finance tab's file is `finance.csv`, newest payment first, with the columns **Paid at**,
**Client**, **Invoice**, **Method**, **Status**, **Gross**, **Tip**, **Refunded**, **Net**,
**Currency** and **Reference**. It counts exactly the payments behind that tab's **Money in**
tile, so the file and the screen agree. See [Finance report](/analytics/finance-report).
The Ads tab's file is `click-to-whatsapp-ads.csv`, busiest ad first, with the columns
**Ad ID**, **Headline**, **Type**, **Conversations**, **Messages**, **New contacts**,
**Impressions** and **Clicks**, plus **Revenue**, **Currency**, **Spend**, **Spend currency**
and **Cost per conversation** when your role can view Finance. It splits the tab's single
**Ad** cell into three columns, and **Type** carries the plain value (`ad` or `post`) rather
than the *From an ad* wording on screen. Spend, impressions and clicks come from a connected
Meta ad account, and are empty cells rather than 0 where nothing has synced. See
[Ads report](/analytics/ads-report).
A formatted version of the single report you're looking at: the same header and accent
styling as an invoice PDF, with metric tiles, bar charts and data tables.
The **Ads** tab's PDF works like the rest: metric tiles, a **Busiest ads** bar chart once
there is more than one ad (the top 8 by conversations, with a line saying so when there are
more), and a table listing every ad. It downloads even when the window is empty, with a line
saying there were no ad-referred conversations in that range.
One PDF covering CSAT, Team performance and Appointments, plus a **Finance** section when
your role can view Finance, plus an **Ads** section when there is an ad
to report in the window. Every section shares the same date window: the trailing 30 days.
Available from any of the six tabs.
The two optional sections are gated on different things. **Finance** is gated on permission:
it appears only for someone allowed to see money. **Ads** is gated on content: it appears
when there is an ad to report in those 30 days, meaning an ad that got a message or, with a
Meta ad account connected, an ad that spent money. With neither, there is no ads page at
all, not even an empty one. A teammate who cannot see money still gets the ads section,
minus its **Revenue** column and **Ad spend** total. So the document runs to five sections
at most (CSAT, Team performance, Appointments, Finance and Ads), four when there was no ad
to report in those 30 days, and three for a teammate without permission to view Finance
in that same workspace.
**Logs is the only tab never in it.** The activity feed is deliberately left out, because it
is noise in a report you would share with someone; export the **Logs** tab on its own if you
need it. The button's hover hint reads *Download every report as a single PDF.*, which still
promises more than the file delivers: Logs is never in it, and Finance is there only for
someone allowed to see money.
Whatever filters you've set on a tab (the date range, or Appointments' staff/service/status
filters) carry into that tab's own CSV and PDF. They do not reach the combined **Workspace
report**: that button carries nothing across, so it always covers the trailing 30 days on every
section, whatever you were looking at. For a specific window, export the tab's own PDF.
The **Finance** tab behaves the same way, with one thing worth knowing before you read its PDF:
the outstanding figures on it are a snapshot of what you are owed right now, so they come out the
same whatever dates you set. See [Finance report](/analytics/finance-report).
Exporting from Reports needs the separate **Export reports** permission, on top of whatever
lets you view Reports in the first place. Three more role rules reach the files. A teammate
without **View team appointments** gets only their own bookings, both in the Appointments tab's
own exports and in the Appointments section of the **Workspace report**. The Finance tab,
its CSV and its PDF all need permission to view Finance, the same permission that opens
invoices, payments and orders; without it the **Workspace report** still downloads, simply
without its Finance section. And the same permission decides how much of the Ads **CSV**, the
Ads **PDF** and the ads section of the **Workspace report** you get: a teammate who cannot view
Finance still gets all three, with the money taken out. The Ads PDF and the ads section of the
Workspace report lose the **Revenue** and **Ad spend** figures; the CSV loses its
**Revenue**, **Currency**, **Spend**, **Spend currency** and **Cost per conversation** columns. The money is stripped out of the file itself, not just
hidden on screen. See
[Roles and permissions](/account/roles-and-permissions).
## What doesn't export
[Overview](/analytics/overview) has no export. The KPI cards, the **Message volume** chart,
the **Broadcast funnel**, **Flow outcomes** and **Contact sources** are on-screen only. If
you need those numbers in a spreadsheet, read the underlying records through the API and do
your own totals.
Two things moved out of this list. The **Appointments** tab on Reports exports a CSV of every
booking in the filtered window, plus a PDF with the same **By service** / **By staff**
breakdowns you see on screen. The **Finance** tab exports one row per payment you collected in
the window, which is as close to a payments export as DMLY gets. See
[Export your reports](#export-your-reports) above.
Conversation transcripts still have no download button anywhere in the app. Pull those records
through the API instead. Invoices are the other exception outside Reports: an individual
invoice downloads as a PDF from **Finance → Invoices** → the row's dropdown →
**Download PDF**. There is no bulk invoice export.
DMLY doesn't email you a report or drop a file anywhere on a timer. Every export is something
you download yourself, when you ask for it.
## Get the rest through the API
For anything without an export, the REST API is the route. It returns JSON, so it suits a
spreadsheet tool that can import from a URL, a reporting tool, or an automation platform like
[n8n](/integrations/n8n).
Keys are minted per workspace and carry the workspace with them, so no workspace ID appears
in any URL. See [Authentication](/api-reference/authentication).
Browse the [API reference](/api-reference/introduction) for contacts, appointments,
invoices, payments and the rest. Lists paginate, so page through them for a full extract.
If you want a running record rather than a snapshot, subscribe to
[webhooks](/api-reference/webhooks) and have each event land in your own system as it
happens.
A key made with **Full access**, or before key permissions existed, can reach everything in the
workspace, including contact phone numbers and conversation content. For an export, choose **Only the areas I choose** and set
just the areas you pull from to **Read** (see [Authentication](/api-reference/authentication)).
Treat any key like a password, and read [Data and privacy](/contacts/data-and-privacy) before
you move contact data out of DMLY.
# Read the finance report
Source: https://docs.dmly.io/analytics/finance-report
See what cash came in, what you billed, what is still owed and what you spent, with the breakdown behind each figure, filterable by date and exportable as CSV or PDF.
**Finance** is the money tab on **Reports**, third of six, sitting between **Appointments** and **Ads** in the tab strip. On screen it introduces itself as *What came in, what was billed, what is still owed and what went out.*
It only reads. Nothing on this page changes a payment, an invoice or an expense; it is the same money you already recorded in [Finance](/finance/overview), totalled up and sliced by date, method, category, offering and client.
The first three tiles answer three different questions and are deliberately never added together. **Money in** is cash that arrived. **Billed** is what you invoiced. **Outstanding** is what you are owed right now, ignoring the dates you set. Read [The three kinds of number](#the-three-kinds-of-number) before you quote any of them to your accountant.
## Open the report
In the sidebar, under **Configurations**, select **Reports**, then select the **Finance** tab.
**Money in**, **Billed**, **Outstanding**, **Spent** and **Net profit**, in that order, in your workspace currency.
A line under the tiles always states which basis each figure is on, and adds a sentence about tips and a sentence about refunds when either applies.
Set **From** and **To** to narrow the window. Leave both blank and the report defaults to the trailing 30 days. Three figures ignore these dates on purpose; see [Filter by date](#filter-by-date).
**CSV** gives you every payment behind **Money in**. **PDF** downloads the whole report formatted. See [Export](#export).
## The three kinds of number
| Figure | The question it answers | Dated by | Moves when you change the dates |
| - | - | - | - |
| **Money in** | How much cash actually reached me? | the day the payment arrived | Yes |
| **Billed** | How much did I invoice? | the invoice's issue date | Yes |
| **Outstanding** | How much do my clients owe me right now? | nothing: it is a running position | No |
**Money in** is cash. It counts money that physically arrived between your two dates, dated by the day it arrived, with refunds already taken off. It does not care when the work was done or when you raised the invoice.
**Billed** is the face value of the invoices you issued between those dates. It does not care whether a penny of it has been paid.
**Outstanding** is a snapshot of what your clients owe you today, across every posted invoice you have ever raised. It is not a period figure, so narrowing it to a period would misrepresent it. Changing **From** and **To** leaves it exactly where it is.
The two windowed figures cross over constantly. An invoice issued inside your window and paid next month counts in **Billed** and not in **Money in**. A payment that lands inside your window against an invoice you raised last year counts in **Money in** and not in **Billed**. Neither is an error, and the gap between them is not a discrepancy to chase.
Do not add **Money in**, **Billed** and **Outstanding** together, and do not treat any one of them as "revenue for the period" without saying which. They are three separate measurements of the same business, on three different bases.
### What each one leaves out
**Money in** excludes:
* **Money that never arrived.** A payment only counts once it is recorded as collected on a date. Pending and failed payments never count, and neither does a payment marked refunded that never actually collected anything.
* **Gift card redemptions.** That money was already counted when the card was sold. Counting it again when the card is spent would count the same sale twice. See [Gift cards](/offerings/gift-cards).
* **Tips.** A gratuity moves money but is not takings. Tips are reported on their own line instead.
* **Refunds.** Refunds are netted off, so **Money in** is what you kept.
**Billed** excludes draft invoices (not owed yet), voided invoices (cancelled), and any posted invoice issued outside the window.
**Outstanding** excludes draft and void invoices. Nothing else is excluded, and nothing is excluded by date.
**Spent** excludes nothing but the window itself. Every expense recorded against a date inside it counts, including expenses you have not put in a category.
A refund returns money; it does not un-collect it. Refunding an invoice lowers **Money in** and does not push the balance back into **Outstanding**. The invoice stays settled.
## What each tile means
| Tile | Basis | What it counts |
| - | - | - |
| **Money in** | Cash | Payments collected in the window, after refunds, excluding tips and gift card redemptions. |
| **Billed** | Invoiced | The total of every invoice you issued in the window, paid or not. Drafts and voids are out. |
| **Outstanding** | Owed now | What is still unpaid across every invoice you have issued, at this moment. Ignores the window. |
| **Spent** | Recorded spend | Expenses dated inside the window. There is no paid or unpaid state on an expense: recording it is the event. |
| **Net profit** | Mixed | **Money in** minus **Spent**. See the note below. |
**Net profit** here is cash you took minus spend you recorded. It is not an accounting profit. It ignores **Billed** and **Outstanding** entirely, so a month where you invoiced heavily and collected little shows a poor net profit even though the business had a good month.
### The lines under the tiles
One line is always shown, word for word:
> Money in is cash that arrived in this period, net of refunds and excluding tips. Billed counts invoices issued in the period. Outstanding is what is owed right now, so it is not limited to this period.
Two more appear only when they apply:
* `Tips of {amount} were collected on top and are not counted as revenue.` shows when any tip was kept in the window. That figure is the gratuity you actually kept, so a refund that ate into a tip has already been deducted from it.
* `{refunded} was refunded out of {gross} taken.` shows when anything was refunded. The larger number is what you took before refunds, which is why it does not match the **Money in** tile.
If takings arrived in more than one currency, an amber line appears above the charts:
`Takings arrived in {count} currencies ({list}). The totals above add them together without conversion.`
Read that line literally. DMLY does not convert currencies here. It adds the raw amounts and tells you it has done so. If your workspace genuinely bills in two currencies, the tiles are a sum of unlike things and only the per-currency split in that line is meaningful. The **Currency** column in the CSV export is the only place each payment's own currency is broken out.
## What the charts and tables show
**Money in and out over time** plots two lines, **Money in** and **Spent**, across the window. Each line adds up to its own tile exactly. A short window is plotted by day. Wider windows are grouped into weeks, and wider ones again into months, so the chart stays readable; the labels along the bottom are then week or month starts rather than days.
**How the money arrived** splits **Money in** by payment method, using the method recorded on each payment (for example **Bank transfer**). The rows add up to the **Money in** tile.
**Where the money went** splits **Spent** by expense category. Spend with no category is kept visible as **Uncategorised** rather than dropped, so the rows still add up.
**Top services and products** is the one breakdown that is *not* cash. Its own sub-line says so: *By value billed on invoices issued in this period.* It totals the value of the lines on the invoices you issued, grouped by the name saved onto the invoice at the time, so renaming a service later does not split its history in two.
**Best clients** lists up to ten clients by **Money in**, with a **Payments** count beside each. **Payments** counts payments, not invoices and not visits; a fully refunded payment still counts as one. Payments with no contact attached to them are grouped under **Unassigned**, which is a real row and not an error.
**What is still owed** ages your unpaid invoices into five buckets: **Not yet due**, **1–30 days**, **31–60 days**, **61–90 days** and **90+ days**, each with an invoice count and an amount, then a bold **Total owed** row that matches the **Outstanding** tile. When anything is past its due date, a red line beneath reads `{amount} of that is past its due date, across {count} invoice(s).` When nothing is owed, the panel simply says **Nothing is outstanding.**
Every bar breakdown shows its top eight rows and folds the rest into a single row labelled **Other (n)**, where `n` is how many rows were folded in, so the bars still add up to the total. **Best clients** is capped at ten rows with no fold row, so it adds up to **Money in** only when ten or fewer clients paid you in the window.
Empty windows say so rather than showing a zero: **No payments were collected in this period.**, **No expenses were recorded in this period.**, or **No data for this period yet.**
## Filter by date
**From** and **To** are the only filters. There is no channel, staff, method or currency filter, and nothing to select to apply them: the report reloads the moment you change a date. A **Clear filter** button appears while either date is set and resets both.
* Leave both blank and you get the trailing 30 days, ending today, both ends included.
* Set only one and the other end still resolves: a **From** on its own runs to today, and a **To** on its own gives the 30 days ending on that date.
* Set **From** after **To** and the window collapses to a single day.
Three things on the page ignore these dates completely: the **Outstanding** tile, the past due line, and the whole **What is still owed** table. They are a position, not a period. Everything else moves.
The window you set is not printed anywhere on the screen. If you need a copy of the report that states its own date range, export the **PDF**: the range appears in its header.
## Export
Three buttons sit at the right of the filter row.
`finance.csv`: one row per payment collected in the window, newest first, with the columns **Paid at**, **Client**, **Invoice**, **Method**, **Status**, **Gross**, **Tip**, **Refunded**, **Net**, **Currency** and **Reference**. It uses exactly the same rules as the tiles, so the **Net** column adds up to **Money in**. **Gross** is the charge without the tip, and **Net** is **Gross** minus **Refunded**.
`finance-report.pdf`: the five tiles, the basis note, the trend chart and every breakdown, formatted like an invoice PDF, with your business header and the date range.
`workspace-report.pdf`: several reports in one document. It carries a Finance section when your role can view finance, and simply leaves that section out when it cannot; either way you get the rest of the document. See [Exporting reports](/analytics/exporting-reports).
**Workspace report** does not carry your dates across. It always covers the trailing 30 days, whatever you set here. For a specific window, export this tab's own **PDF**.
The **PDF** is in English whatever language the app is set to. The screen is translated; the printed report's headings are not. Its wording differs slightly too, for instance **Top services and products** prints as **Top services & products billed**.
## Who can see the Finance tab
Two permissions decide it, and neither is new.
* To reach the report at all you need access to the **Reports** module, and the **Finance** module's **View finance (invoices, payments, orders)** permission: the same one that opens the Finance section itself. Without the second, the tab is not shown, and going to the address directly is refused with *You do not have permission to view finance.*
* Owners and Admins hold both. Members and Viewers hold **View finance** by default, so they see the tab. A custom role only sees it if you tick that permission.
Exporting is separate again: **CSV**, **PDF** and **Workspace report** all need **Export reports**, which is admin only by default. Those three buttons are shown to everyone on the page, so a Member can see them and still be turned away on selecting one. That is true of every Reports tab, not just this one. See [Roles and permissions](/account/roles-and-permissions).
**View finance** reaches one more place: the **Revenue** column on the [Ads](/analytics/ads-report) tab. That tab is handled the other way round. It stays on the strip for anyone who can open Reports, and a role without **View finance** simply gets it without the money: no **Revenue** card, no **Revenue** column, and no revenue figure in its CSV either.
## Caveats
It is not meant to. Five things sit between them, and all five are deliberate: tips are excluded, gift card redemptions are excluded, refunds are already netted off, payments you have not recorded as collected are not counted, and takings in different currencies are added without conversion. Work down that list before assuming a payment is missing.
Correct. **Outstanding**, the past due line and the **What is still owed** table are a snapshot of what you are owed right now, across every invoice you have ever issued. A period filter would make them a different, and misleading, number.
Expected. That panel totals the value of invoice lines, while **Billed** totals invoices. A coupon, a discount or a shipping charge moves the invoice total away from the sum of its lines. It is also capped at eight rows plus **Other (n)**, and it never sees anything you sold without an invoice line. Treat it as a ranking, not as a breakdown of **Billed**.
The appointments report has a money figure of its own, printed as **Paid bookings** in its PDF. It totals what bookings are worth once they are marked paid, counted on the date of the booking rather than the day the money arrived, so it includes bookings that have not happened yet and takes no account of refunds. It is not **Money in** and the two are not meant to reconcile. For cash actually taken, use this page.
It values a payment almost the same way this page does: only money recorded as collected, tips left out, refunds already taken off, and dated by the day the money arrived. One difference: this page never takes off more refund than a payment's tip-free amount, while the Ads report takes off the whole refund, so a refunded tip can make an ad's revenue lower than here, even below zero. What differs is which payments it looks at. The [Ads report](/analytics/ads-report) counts only payments from the contacts each ad won you, so it is a slice of your takings rather than all of them, and the ad rows will never add up to **Money in**.
One difference is not just scope. A gift card being spent is left out of **Money in** here, because that money was counted when the card was sold; the Ads tab does not make that exclusion, so a redemption by an ad-won contact can show up as ad revenue and not as **Money in**. Quote this page for takings, and the Ads tab for which ad they came from.
This report works out what is overdue from each invoice's due date every time it loads, so an invoice can show as past due here just before its own status catches up. Invoices with no due date at all are never counted as overdue; they sit in the **Not yet due** bucket, because undated money is not late money.
The bucket names are rounded. An invoice exactly 90 days past its due date sits in **61–90 days**, and **90+ days** starts a day later. Nothing is lost either way: every unpaid invoice lands in exactly one bucket, and the buckets add up to **Total owed**.
The date window is applied in UTC rather than your local time, so a payment taken late in the evening well east of UTC can fall into the next day's bucket. Over a window of any length the totals are unaffected; it only shows up at the edges of a narrow window.
## Next
Where these numbers come from: invoices, payments, orders and subscriptions.
What feeds **Spent** and **Where the money went**.
The next tab along: what each Click-to-WhatsApp ad returned.
Every export across Reports, in one place.
Who can see the Finance tab, and who can export from it.
# Overview
Source: https://docs.dmly.io/analytics/overview
Read the Overview dashboard: quick actions, at-a-glance numbers, message volume, peak conversation hours, flow outcomes, broadcast funnel and contact sources.
**Overview** is the first page you land on: your dashboard for how contacts, conversations, automations and broadcasts are doing on the channel you have selected, plus what is happening right now. For the deeper reports (team performance, appointments, finance, Click-to-WhatsApp ads, CSAT and the activity log), see **Configurations → Reports**, covered on their own pages linked at the bottom of this one.
Almost everything on Overview follows the channel you have selected at the top of the page. Switch channel and the numbers change with it; the exception is **Upcoming appointments**, which is workspace-wide and does not change with the channel. See [Navigating DMLY](/getting-started/navigating-dmly).
## Start your day here
The top of the page greets you by name and names your business, then gives you a row of shortcuts to the things you open most: **Open inbox**, **Appointments**, **Send broadcast**, **Contacts**, **Invoices** and **Get booking link**, which opens your booking link ready to share.
You only see the shortcuts for the areas your role can reach. If **Send broadcast** or **Invoices** is missing for you, that is your permissions, not a fault. See [Roles and permissions](/account/roles-and-permissions).
## At a glance
The **At a glance** cards are your headline numbers. Every card except the two call cards is always shown.
| Card | What it counts |
| - | - |
| **Contacts** | How many contacts you have. |
| **Messages sent** | Outbound messages. |
| **Reply rate** | The share of your contacts who have ever messaged you back. |
| **Flow completion** | Of the automation runs that finished, the share that completed instead of failing or being cancelled. |
| **Broadcast delivery** | Delivered messages as a share of messages sent, across all your broadcasts. |
| **Growth clicks** | Clicks on your [growth tools](/growth/overview). |
| **Opt-out rate** | The share of your contacts who have opted out. |
| **Active sequences** | Contacts currently moving through a [sequence](/automation/sequences). |
Two more cards, **Calls** and **Call answer rate**, appear when your selected channel is a WhatsApp number that has calling switched on or has had calls. With calling on and no calls yet, they read 0 and 0%.
These cards are all-time totals for the selected channel. There is no date range or period picker on Overview, and no comparison against a previous period.
## The panels
Outbound versus inbound messages over the last 14 days, as two lines. Fixed at 14 days. Reads **No message activity yet.** until messages start moving.
The six most recent events on the selected channel (including workspace-wide business activity such as invoices, appointments, payments, reviews and automation failures), each with a status colour and how long ago it happened. For the full history see [Logs](/troubleshooting/logs).
A heatmap of inbound messages by weekday and hour, over the last 28 days. Darker cells mean more messages arrived at that hour. The chart notes which timezone the hours are shown in.
Your top six [growth tools](/growth/overview) by clicks, each with the contacts it brought in and a click-to-contact conversion rate, so you can tell a link that gets clicked but doesn't start a conversation from one that works.
Your next bookings (service, contact, time and staff member) with **View all** through to [Appointments](/appointments/overview).
Where your automation runs end up: **Completed**, **Running**, **Waiting**, **Failed**, **Cancelled**. A climbing **Failed** count is your cue to open [Automation not triggering](/troubleshooting/automation-not-triggering).
**Recipients → sent → delivered → opened → replies**, each step as a count and as a percentage of recipients, so you can see where a campaign drops off.
Your six biggest contact sources by count: where people are actually coming from. Somebody who tapped a Click-to-WhatsApp ad arrives here named after the ad, as `WhatsApp ad: Summer Sale`. Reads **No contact source data yet.** until contacts carry a source.
**Peak conversation hours** and **Growth-tool attribution** sit side by side, the heatmap taking the wider share of the row. **Upcoming appointments** lists confirmed one-to-one bookings only, in the contact's own timezone. Class seats are not listed here. Open [Appointments](/appointments/overview) for those. The panel is hidden entirely if your role has no access to Appointments.
## What Overview does not do
Nothing on Overview can be exported. The export buttons sit on the Reports tabs instead. See [Exporting reports](/analytics/exporting-reports).
The only filter is the channel selector. **Message volume** is fixed to the last 14 days, **Peak conversation hours** to the last 28 days, and every other number is all-time. The 14-day and 28-day windows don't line up with each other, so don't expect the two charts to visually agree on how busy a given day was.
No **At a glance** card totals what you took, what you billed or what you are owed. **Recent activity** lists invoices and payments as events, not as amounts. For the money itself, open **Configurations → Reports** and select the **Finance** tab. See [Finance report](/analytics/finance-report).
For one automation's own performance (runs, completion rate, messages sent, last run and its recent activity), open the automation and use **Analytics**. See [Automation](/automation/overview).
Overview is not restricted by plan. What you see is set by your role only. If a panel or shortcut is missing, check [Roles and permissions](/account/roles-and-permissions).
## What to check next
How to read inbound versus outbound volume and reply rate.
Response rate, average score and satisfaction, across every survey you've sent.
Assignments, replies and response times, per teammate.
Money in, billed, outstanding and spent, on the Finance tab of Reports.
What each Click-to-WhatsApp ad returned: conversations, contacts and revenue.
The full activity feed behind Overview's Recent activity panel.
# Read the team performance report
Source: https://docs.dmly.io/analytics/team-performance
See how each teammate handles conversations (assignments, closes, replies, contacts and response times), filterable by date and exportable as CSV or PDF.
**Team performance** is the first of the six tabs on **Reports**, alongside Appointments, Finance, Ads, CSAT and Logs. It is the tab the section opens on. It shows, per teammate, how many conversations they were assigned, how many they closed, how many replies they sent, how many distinct contacts they reached, and how quickly (an average first-response time and an average resolution time) across every channel.
In the sidebar, under **Configurations**, select **Reports**. It opens on the **Team** tab, so there is nothing else to click.
Five cards summarise the whole team for the window: **Assigned**, **Closed**, **Replies**, **Avg first response**, **Avg resolution**.
**By agent** breaks the same figures down per teammate, plus **Contacts** (the number of distinct contacts they personally replied to), sorted by whoever has replied the most.
Set **From** and **To** to narrow the window. Leave both blank and the report defaults to the trailing 30 days.
**CSV** gives you one row per agent with every column from the table. **PDF** downloads a formatted team performance report; **Workspace report** downloads one PDF covering CSAT, Team performance and Appointments together, plus a [Finance](/analytics/finance-report) section when your role can view Finance and an [Ads](/analytics/ads-report) section when the workspace has ad-referred conversations to report. **Logs** is never in it, and it always covers the trailing 30 days rather than the window you set here. See [Exporting reports](/analytics/exporting-reports).
Team performance is workspace-wide; like CSAT, it is not filtered by the channel selector. A WhatsApp assignment and a Facebook assignment both count toward the same agent's numbers.
## What each column means
| Column | What it counts |
| - | - |
| **Assigned** | How many times a conversation was assigned to this agent in the window. A conversation reassigned twice counts twice, split across whoever received it. |
| **Closed** | Conversations this agent closed in the window. A conversation closed automatically by an automation (nobody actually clicked Close) isn't credited to anyone, so it never appears in this column or in the team total. |
| **Replies** | Outbound messages this agent personally sent. A bot or automation reply is never attributed to a person, so it doesn't count here either. |
| **Contacts** | The number of distinct contacts this agent replied to in the window. |
| **Avg first response** | How long it took this agent to send their first reply after a conversation was assigned to them, measured only against replies sent before that conversation moved to someone else. |
| **Avg resolution** | How long a conversation stayed open before this agent closed it, measured from whichever is later: the assignment that gave it to them, or the last time it was reopened. |
The **Activity over time** chart lines up **Replies** and **Closed** per day across the window, counting the same population as the cards above it (so the two views always agree with each other).
A teammate with no assignments, closes or replies in the window simply doesn't appear in **By agent**; the table only lists agents who did something.
## Troubleshooting
If a closed conversation is reopened and then closed again, **Avg resolution** for that second close is measured from the reopen, not from the original assignment, so the dormant time the conversation spent closed doesn't inflate the figure. If a conversation is reassigned before the agent replies, only a reply that lands before the next reassignment counts toward **Avg first response** for that assignment, so one reply can't be credited to two different assignments.
Closes that an automation performs on its own (no teammate involved) aren't credited to any agent, so they're excluded from every figure on this page, including the team totals. If you close conversations mostly through a flow rather than manually, that's expected; check [Overview](/analytics/overview)'s **Flow outcomes** panel for the bot's own completion numbers instead.
Check the date range first: the default is the trailing 30 days, so older activity won't show until you widen **From**. If the window looks right, confirm the agent is still an active member of the workspace; a removed teammate's past activity still counts toward the team totals, but the per-agent row falls back to a generic "User #…" name rather than being dropped.
## Next
Another Reports tab: response rate, average score and satisfaction.
The money tab: what came in, what was billed and what is still owed.
Another Reports tab: what each Click-to-WhatsApp ad returned.
Every export across Reports, in one place.
Who can see Reports, and who can export from it.
The last Reports tab: the activity feed behind a failed send or a stalled flow.
# Get the authenticated workspace
Source: https://docs.dmly.io/api-reference/account/get-the-authenticated-workspace
/api-reference/openapi.json get /account
Returns the workspace (account) that the API key belongs to. Useful as a connectivity / auth check.
# Agency authentication
Source: https://docs.dmly.io/api-reference/agency-authentication
Authenticate the reseller API with an agency key (dmly_ag_…), sent as the x-api-key header.
Every request carries an **agency API key**. It authenticates the acting agency and scopes every
request to that agency's own sub-accounts; an agency can only ever see and act on its own
workspaces.
An agency key starts `dmly_ag_`, which is what distinguishes it from a workspace key (`dmly_…`). The
two are **not interchangeable**: an agency key is rejected by the workspace API, and a workspace key
is rejected here.
## Sending the key
Send it as the `x-api-key` header:
```bash theme={"dark"}
curl https://dash.dmly.io/api/agency/v1/workspaces \
-H "x-api-key: dmly_ag_xxxxxxxxxxxx"
```
A bearer token is also accepted:
```bash theme={"dark"}
curl https://dash.dmly.io/api/agency/v1/workspaces \
-H "Authorization: Bearer dmly_ag_xxxxxxxxxxxx"
```
Both are equivalent. If both are present, `x-api-key` wins.
## Creating a key
In the agency console, go to **Configuration → API**.
Select **+ Generate key**. Give it a name describing what will use it, so you can recognise it
later when rotating or revoking, then select **Generate**.
The key is shown **once**, under **Copy your API key now**. DMLY stores only a hash of it, so it
cannot be retrieved or emailed to you afterwards.
An agency key can provision workspaces and change subscriptions across every one of your
sub-accounts. Treat it like a password: keep it server-side, never commit it, and never ship it in
client-side code.
## Rotating and revoking
Create the replacement key first, deploy it, then revoke the old one. Revocation takes effect
immediately, and any caller still using the old key starts getting `401`. DMLY stamps a last-used
time on every authenticated request, so you can tell whether a key is still in use before retiring
it.
## When authentication fails
| Status | Body | Meaning |
| - | - | - |
| `401` | `API key missing. Send it as the "x-api-key" header or a Bearer token.` | No key on the request |
| `401` | `Invalid API key.` | Unknown or revoked key |
| `403` | `This agency is not active.` | Your agency has been suspended; all its keys stop working |
| `429` | `Too many requests. Retry after N seconds.` | You exceeded 120 requests per minute on this key |
| `503` | `The reseller API is currently disabled.` | The whole surface is switched off fleet-wide |
A `503` is not about your key; it means DMLY has temporarily disabled the reseller API for
everyone, typically during an incident. Retry later; your key still works once it is back on.
## Rate limit
The reseller API allows **120 requests per minute per agency key**. That is a separate, larger
budget than the workspace REST API's 60 per minute, because one agency key manages many
sub-accounts.
Requests whose key doesn't resolve (missing, mistyped or revoked) fall back to a shared **60
requests per minute per IP address** instead. They still fail with `401`; the shared bucket only
caps how fast you can retry.
On a `429`, wait the number of seconds in `Retry-After` (the message states the same figure) and
retry.
# Connect a customer's channels
Source: https://docs.dmly.io/api-reference/agency-channel-connect
Let your customers connect WhatsApp, Messenger, Instagram, TikTok and Telegram from inside your own app, without signing in to DMLY.
Channel Connect lets your app connect a channel to one of your sub-accounts. Your customer clicks
**Connect WhatsApp** (or Messenger, Instagram or TikTok) in your product, authorizes with Meta or
TikTok on a page carrying your brand, picks the account, and lands back in your app. They never see
the DMLY dashboard, and your backend gets the connected channel's id. Telegram is simpler still:
your app sends DMLY the bot token and gets the channel back.
Channel Connect is available to every agency on the Agency API: `GET /channel-connect/settings`
reports `"enabled": true`. Register your return URLs and you can start.
## What it supports
| Platform | `platform` | How the customer connects |
| - | - | - |
| WhatsApp | `whatsapp` | Meta's Embedded Signup, then they pick the number |
| Messenger | `facebook` | Facebook Login, then they pick the Page |
| Instagram (via Facebook Page) | `instagram` | Facebook Login, then they pick the professional account linked to one of their Pages |
| Instagram (Instagram Login) | `instagram_login` | Instagram sign-in with a Business or Creator account. No Facebook Page needed |
| TikTok | `tiktok` | TikTok login with a TikTok Business account |
| Telegram | none | No hosted page: your app sends the bot token to [`POST /workspaces/{workspace}/channels/telegram`](#telegram) |
WhatsApp numbers can also be connected in [co-existence](#whatsapp-business-app-co-existence) with the
WhatsApp Business app.
### Instagram without a Facebook Page
Use `instagram_login` for an Instagram account that isn't linked to a Facebook Page. The hosted page
tells the customer *No Facebook Page is needed* and sends them to instagram.com, where they always
sign in afresh, so an Instagram account already signed in on that browser can't be attached by
mistake. One sign-in connects one account: there is no picker.
* The account must be a **professional** account: Business or Creator. A personal account fails
with `business_account_required`.
* The customer must grant the messaging permission, or the session fails with
`required_permissions_missing`. Comment and publishing permissions are asked for too, so comment
automations (including comment-to-DM) work, but they aren't required.
* The result is an ordinary Instagram channel (`platform` on the channel is still `instagram`). It
counts against the same Instagram allowance and channel caps as one connected through a Page.
`instagram` is unchanged: Facebook Login and a professional account linked to a Page the customer
administers.
**One messaging channel per Instagram account per workspace.** If the account is already a channel
through its Page, an `instagram_login` session for it fails with `instagram_connected_via_facebook`.
The reverse, an `instagram` session for an account already connected through Instagram Login, fails
with `instagram_connected_via_instagram_login`. To switch the account from one route to the other,
start the session again with `channel_id` set to the existing channel. It is switched in place: the
channel id stays the same, so its automations and conversation history are kept.
## How it works
Tell DMLY where customers may be sent back to. Only these exact URLs are accepted.
```bash theme={"dark"}
curl -X PUT https://dash.dmly.io/api/agency/v1/channel-connect/settings \
-H "x-api-key: dmly_ag_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"return_urls": ["https://app.example.com/integrations/callback"]}'
```
Up to 20 HTTPS URLs, with no query string, fragment or credentials. Matching is exact,
including the path and any trailing slash. The call replaces the whole list.
When your customer clicks Connect, your server creates a session for their sub-account.
```bash theme={"dark"}
curl -X POST https://dash.dmly.io/api/agency/v1/workspaces/{workspace}/channel-connections \
-H "x-api-key: dmly_ag_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6c1d0e9a-connect-whatsapp" \
-d '{
"platform": "whatsapp",
"return_url": "https://app.example.com/integrations/callback",
"state": "cust_8841"
}'
```
The response carries a `connect_url`. Send it to the browser and nowhere else.
```json theme={"dark"}
{
"data": {
"id": "6f0e2c1a-8b7d-4c3e-9a1f-0d2b3c4e5f60",
"workspace_id": "11111111-1111-1111-1111-111111111111",
"platform": "whatsapp",
"status": "pending",
"channel_id": null,
"error_code": null,
"state": "cust_8841",
"expires_at": "2026-10-06T12:30:00+00:00",
"connect_url": "https://dash.dmly.io/agency-connect/6f0e2c1a-…?token=…"
}
}
```
Open `connect_url` as a top-level navigation (a link or a redirect, not an iframe or a
background fetch). The customer sees your agency's name, clicks **Continue to Meta** (or TikTok),
authorizes, and chooses the account to connect. They must finish in the same browser they
started in, within 30 minutes.
The browser returns to your `return_url` with `connection_id` and `state` in the query string.
Match `state` to the customer who started, then ask DMLY for the result. Never trust the
query string alone.
```bash theme={"dark"}
curl https://dash.dmly.io/api/agency/v1/workspaces/{workspace}/channel-connections/{connection} \
-H "x-api-key: dmly_ag_xxxxxxxxxxxx"
```
When `status` is `connected`, `channel_id` is the new channel. Read it with
`GET /workspaces/{workspace}/channels/{channel}`.
## Before you start
* **Keep the agency key on your server.** Create sessions from your backend only. An agency key can
act on every sub-account you own, so your backend must check that the signed-in customer owns the
workspace before it creates a session for it.
* **Treat `connect_url` as a secret.** Anyone holding it can start that connection. Don't log it,
put it in analytics, or send it by email. Only the create call returns it (and an
`Idempotency-Key` replay of it while the session is still `pending`); status reads never do.
* **Use `state` for correlation only.** Up to 500 characters, returned unchanged on the return URL,
in status responses and in webhooks. Don't put secrets or personal data in it.
* **Retry safely with `Idempotency-Key`.** Repeating the same request with the same key returns the
same session (`200`). The same key with a different body or sub-account returns `409`. Use a new
key to start over.
## Statuses
| Status | Meaning |
| - | - |
| `pending` | Created. The customer hasn't continued to Meta or TikTok yet |
| `authorizing` | The customer is on Meta's or TikTok's consent screens |
| `selecting` | Meta or TikTok approved; the customer is choosing the account |
| `connected` | Done. `channel_id` is set |
| `failed` | Something went wrong. See `error_code` |
| `cancelled` | The customer declined at Meta or TikTok, or you cancelled the session |
| `expired` | 30 minutes passed without finishing |
`connected` means the account was saved and, for Meta accounts, its webhook subscription
succeeded. Messages still depend on the account itself: a WhatsApp number must be able to send, a
Page must allow messaging, and Meta's and TikTok's usual messaging rules apply.
To abandon a session, call `POST /workspaces/{workspace}/channel-connections/{connection}/cancel`.
It is safe to repeat and never disconnects a channel that already connected.
## Reconnecting a channel
To refresh an existing channel (an expired token, a revoked permission), pass its id as
`channel_id` when you create the session. The customer must pick the same number, Page or
Instagram account; anything else fails with `reconnection_target_mismatch`. The channel keeps its
id, so nothing changes on your side.
## WhatsApp Business app co-existence
A business that already uses the WhatsApp Business app on its phone can keep using it: in
co-existence the number stays on the app, and DMLY sends and receives through the Cloud API
alongside it. Add `"coexistence": true` when you create a `whatsapp` connection (it is refused for
any other platform):
```json theme={"dark"}
{
"platform": "whatsapp",
"return_url": "https://app.example.com/integrations/callback",
"coexistence": true
}
```
Meta's signup then asks the customer to scan a code with the WhatsApp Business app instead of
registering the number. Once connected, DMLY asks Meta for the app's contacts and recent chat
history, which Meta allows only within 24 hours of onboarding, and they appear in the Inbox as they
arrive. Meta decides which numbers qualify; one that doesn't is turned away on Meta's screens and the
session ends `cancelled` or `failed`. The connection's `coexistence` field echoes what you asked for.
## Telegram
Telegram has no login screen: the customer creates a bot with [@BotFather](https://t.me/BotFather)
and copies its token. Collect the token in your app and send it from your backend:
```bash theme={"dark"}
curl -X POST https://dash.dmly.io/api/agency/v1/workspaces/{workspace}/channels/telegram \
-H "x-api-key: dmly_ag_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"bot_token": "123456789:AA…", "name": "Sparkle Cleaning"}'
```
DMLY checks the token with Telegram, creates the channel, points the bot's webhook at DMLY and
returns the channel: `201` for a new bot, `200` when the same bot was already connected (a new token
for it refreshes the channel in place). `name` is optional. A token Telegram rejects, or a bot
already connected to another DMLY workspace, returns `422` with an error on `bot_token`. The token is
never returned to you. It needs Channel Connect enabled, like the rest of this page.
## Webhooks
Subscribe your [agency webhook](/api-reference/agency-webhooks) to any of:
* `channel.connection.completed`
* `channel.connection.failed`
* `channel.connection.cancelled`
* `channel.connection.expired`
`data` is the connection as `GET …/channel-connections/{connection}` returns it, without
`connect_url`. Events are sent within about a minute of the session ending, at least once, so
deduplicate on the envelope `id`. They report how onboarding ended, not later problems such as
an expired token. Reading the status when the customer returns is still the quickest way to show
them the result.
## Error codes
| `error_code` | What happened | What to do |
| - | - | - |
| `authorization_not_completed` | The customer declined or closed the consent screens | Let them start again |
| `required_permissions_missing` | The customer unticked a permission messaging needs | Start again and keep every permission ticked |
| `no_eligible_accounts` | Nothing to connect: no WhatsApp number, no Page, or no Instagram professional account linked to a Page | Set the account up in Meta first, then retry |
| `account_already_connected_elsewhere` | That number, Page or account is connected to another DMLY workspace (for Instagram, through either route) | Disconnect it there first |
| `channel_limit_reached` | The sub-account's plan has no room for another channel of this kind | Raise the plan or add an add-on, then retry |
| `reconnection_target_mismatch` | A reconnect picked a different account than `channel_id` | Pick the account the channel already uses |
| `subscription_failed` | Meta or Instagram refused to send this account's messages to DMLY | Retry; if it repeats, contact support |
| `credentials_expired` | The authorization expired before the customer chose an account | Start again |
| `token_exchange_failed` | Meta or TikTok didn't complete the authorization | Start again |
| `asset_discovery_incomplete` | The customer has too many accounts for DMLY to list | Contact support |
| `provider_request_failed` | Meta or TikTok rejected a request | Retry; if it repeats, contact support |
| `provider_unavailable` | Meta or TikTok couldn't be reached | Retry in a few minutes |
| `provider_not_configured` | Connecting this platform isn't set up on DMLY's side (for `instagram_login`, Instagram Login isn't switched on for your DMLY install) | Contact support |
| `business_account_required` | TikTok or Instagram Login: the account is personal. TikTok needs a Business account; Instagram needs a Business or Creator account | Switch the account type in the TikTok or Instagram app, then retry |
| `instagram_connected_via_facebook` | `instagram_login`: this Instagram account is already a channel in the workspace through its Facebook Page | Retry with `channel_id` set to that channel to switch it to Instagram Login |
| `instagram_connected_via_instagram_login` | `instagram`: this Instagram account is already a channel in the workspace through Instagram Login | Retry with `channel_id` set to that channel to switch it to the Page connection |
| `messaging_unavailable_in_region` | TikTok: Business Messaging isn't available in the account's region | Nothing to fix on your side; TikTok doesn't offer it there |
| `session_expired` | 30 minutes passed | Start a new session |
Meta's and TikTok's error details and tokens are never returned to you.
## What your customer sees
The connect page shows your agency's app name (or agency name) and nothing from the DMLY dashboard.
It is served from your verified custom domain when you have one, otherwise from your agency
subdomain, otherwise from DMLY's own domain. Meta's and TikTok's own consent screens show the name of
the app your customer is authorizing, which DMLY controls, not yours.
For Messenger and Instagram the consent screen asks for the same permissions as a connection made in
the DMLY dashboard, including comment permissions, so comment automations work on the channel. Only
the messaging permissions are required; a customer who withholds the others still connects for
direct messages.
# Cancel a channel connection.
Source: https://docs.dmly.io/api-reference/agency-channel-connect/cancel-a-channel-connection
/api-reference/agency-openapi.json post /workspaces/{workspace}/channel-connections/{connection}/cancel
Cancels an unfinished session and discards its temporary Meta credentials. Safe to repeat. A session that already finished keeps its result: cancelling never disconnects a connected channel.
# Connect a Telegram bot.
Source: https://docs.dmly.io/api-reference/agency-channel-connect/connect-a-telegram-bot
/api-reference/agency-openapi.json post /workspaces/{workspace}/channels/telegram
Telegram has no consent screen, so there is no hosted session: your app collects the customer's BotFather bot token and sends it here from your backend. DMLY validates it with Telegram, creates the channel, points the bot's webhook at DMLY and returns the channel, with `meta.webhook_registered`. Returns 201 for a new bot and 200 when the same bot is already connected to this sub-account (a new token for it refreshes the channel in place). Returns 422 with an error on `bot_token` when Telegram rejects the token or the bot is connected to another DMLY workspace, 422 on `channel` when the plan has no room, and 403 when Channel Connect is not enabled for your agency or the sub-account is suspended. The token is never returned.
# List a sub-account's channels.
Source: https://docs.dmly.io/api-reference/agency-channel-connect/list-a-sub-accounts-channels
/api-reference/agency-openapi.json get /workspaces/{workspace}/channels
The channels connected to this sub-account, oldest first. Paginated with `per_page` (default 25, maximum 100) and `page`. Never includes tokens.
# Read a channel.
Source: https://docs.dmly.io/api-reference/agency-channel-connect/read-a-channel
/api-reference/agency-openapi.json get /workspaces/{workspace}/channels/{channel}
One channel on this sub-account, by the id a connection returned. Never includes tokens.
# Read a channel connection.
Source: https://docs.dmly.io/api-reference/agency-channel-connect/read-a-channel-connection
/api-reference/agency-openapi.json get /workspaces/{workspace}/channel-connections/{connection}
The authoritative result of a session. Read it when your customer comes back to your return URL rather than trusting the query string. A session past its expiry is reported as `expired`. `connected` means the account was stored and its Meta webhook subscription succeeded; `channel_id` is then set.
# Read Channel Connect settings.
Source: https://docs.dmly.io/api-reference/agency-channel-connect/read-channel-connect-settings
/api-reference/agency-openapi.json get /channel-connect/settings
Returns whether Channel Connect is enabled for your agency (`enabled`) and your registered `return_urls`. Channel Connect is available to every agency, so `enabled` is true; if DMLY ever switches it off, creating a connection returns 403.
# Replace your registered return URLs.
Source: https://docs.dmly.io/api-reference/agency-channel-connect/replace-your-registered-return-urls
/api-reference/agency-openapi.json put /channel-connect/settings
Replaces the list of URLs a customer may be returned to after connecting. Up to 20 distinct HTTPS URLs, with no credentials, query string or fragment. Matching is exact, including path and trailing slash. Removing a URL also stops unfinished sessions from completing against it. This does not enable the feature.
# Start a channel connection.
Source: https://docs.dmly.io/api-reference/agency-channel-connect/start-a-channel-connection
/api-reference/agency-openapi.json post /workspaces/{workspace}/channel-connections
Creates a 30-minute session that lets your customer connect a WhatsApp number, Facebook Page (Messenger), Instagram account or TikTok Business account to this sub-account without signing in to DMLY. Send your customer to `connect_url` as a top-level navigation; they authorize with Meta or TikTok, choose the account, and are returned to your `return_url` with `connection_id` and `state`. `return_url` must be one you registered with `PUT /channel-connect/settings`. To reconnect an existing channel, pass its id as `channel_id`; the account the customer picks must be the same one. An optional `Idempotency-Key` header (1 to 200 characters) makes retries safe: the same key with the same body returns the existing session with 200, with a different body or workspace returns 409. Returns 403 when Channel Connect is not enabled for your agency or the sub-account is suspended, and 422 when the return URL is not registered or the plan does not allow the platform. For WhatsApp, `coexistence: true` onboards the number in WhatsApp Business app co-existence (the number stays on the app); it is refused for other platforms. Telegram has no hosted session: use `POST /workspaces/{workspace}/channels/telegram`.
# Add on granted
Source: https://docs.dmly.io/api-reference/agency-events/add-on-granted
/api-reference/agency-openapi.json webhook addon.granted
Sent to your endpoint when **Add-on granted** occurs (`addon.granted`).
Signed as `X-Dmly-Signature: sha256=` (HMAC-SHA256 of the raw body with your endpoint's secret) when the endpoint has one, alongside `X-Dmly-Event` and `X-Dmly-Delivery` headers. Return any 2xx to acknowledge; a non-2xx is retried twice more, after roughly 10s then 60s (three attempts in total).
# Add on revoked
Source: https://docs.dmly.io/api-reference/agency-events/add-on-revoked
/api-reference/agency-openapi.json webhook addon.revoked
Sent to your endpoint when **Add-on revoked** occurs (`addon.revoked`).
Signed as `X-Dmly-Signature: sha256=` (HMAC-SHA256 of the raw body with your endpoint's secret) when the endpoint has one, alongside `X-Dmly-Event` and `X-Dmly-Delivery` headers. Return any 2xx to acknowledge; a non-2xx is retried twice more, after roughly 10s then 60s (three attempts in total).
# Channel connection cancelled
Source: https://docs.dmly.io/api-reference/agency-events/channel-connection-cancelled
/api-reference/agency-openapi.json webhook channel.connection.cancelled
Sent to your endpoint when **Channel connection cancelled** occurs (`channel.connection.cancelled`).
Signed as `X-Dmly-Signature: sha256=` (HMAC-SHA256 of the raw body with your endpoint's secret) when the endpoint has one, alongside `X-Dmly-Event` and `X-Dmly-Delivery` headers. Return any 2xx to acknowledge; a non-2xx is retried twice more, after roughly 10s then 60s (three attempts in total).
Sent within about a minute of the session ending. `data` is the connection, as `GET /workspaces/{workspace}/channel-connections/{connection}` returns it.
# Channel connection completed
Source: https://docs.dmly.io/api-reference/agency-events/channel-connection-completed
/api-reference/agency-openapi.json webhook channel.connection.completed
Sent to your endpoint when **Channel connection completed** occurs (`channel.connection.completed`).
Signed as `X-Dmly-Signature: sha256=` (HMAC-SHA256 of the raw body with your endpoint's secret) when the endpoint has one, alongside `X-Dmly-Event` and `X-Dmly-Delivery` headers. Return any 2xx to acknowledge; a non-2xx is retried twice more, after roughly 10s then 60s (three attempts in total).
Sent within about a minute of the session ending. `data` is the connection, as `GET /workspaces/{workspace}/channel-connections/{connection}` returns it.
# Channel connection expired
Source: https://docs.dmly.io/api-reference/agency-events/channel-connection-expired
/api-reference/agency-openapi.json webhook channel.connection.expired
Sent to your endpoint when **Channel connection expired** occurs (`channel.connection.expired`).
Signed as `X-Dmly-Signature: sha256=` (HMAC-SHA256 of the raw body with your endpoint's secret) when the endpoint has one, alongside `X-Dmly-Event` and `X-Dmly-Delivery` headers. Return any 2xx to acknowledge; a non-2xx is retried twice more, after roughly 10s then 60s (three attempts in total).
Sent within about a minute of the session ending. `data` is the connection, as `GET /workspaces/{workspace}/channel-connections/{connection}` returns it.
# Channel connection failed
Source: https://docs.dmly.io/api-reference/agency-events/channel-connection-failed
/api-reference/agency-openapi.json webhook channel.connection.failed
Sent to your endpoint when **Channel connection failed** occurs (`channel.connection.failed`).
Signed as `X-Dmly-Signature: sha256=` (HMAC-SHA256 of the raw body with your endpoint's secret) when the endpoint has one, alongside `X-Dmly-Event` and `X-Dmly-Delivery` headers. Return any 2xx to acknowledge; a non-2xx is retried twice more, after roughly 10s then 60s (three attempts in total).
Sent within about a minute of the session ending. `data` is the connection, as `GET /workspaces/{workspace}/channel-connections/{connection}` returns it.
# Sub account created
Source: https://docs.dmly.io/api-reference/agency-events/sub-account-created
/api-reference/agency-openapi.json webhook workspace.created
Sent to your endpoint when **Sub-account created** occurs (`workspace.created`).
Signed as `X-Dmly-Signature: sha256=` (HMAC-SHA256 of the raw body with your endpoint's secret) when the endpoint has one, alongside `X-Dmly-Event` and `X-Dmly-Delivery` headers. Return any 2xx to acknowledge; a non-2xx is retried twice more, after roughly 10s then 60s (three attempts in total).
# Sub account restored
Source: https://docs.dmly.io/api-reference/agency-events/sub-account-restored
/api-reference/agency-openapi.json webhook workspace.restored
Sent to your endpoint when **Sub-account restored** occurs (`workspace.restored`).
Signed as `X-Dmly-Signature: sha256=` (HMAC-SHA256 of the raw body with your endpoint's secret) when the endpoint has one, alongside `X-Dmly-Event` and `X-Dmly-Delivery` headers. Return any 2xx to acknowledge; a non-2xx is retried twice more, after roughly 10s then 60s (three attempts in total).
# Sub account suspended
Source: https://docs.dmly.io/api-reference/agency-events/sub-account-suspended
/api-reference/agency-openapi.json webhook workspace.suspended
Sent to your endpoint when **Sub-account suspended** occurs (`workspace.suspended`).
Signed as `X-Dmly-Signature: sha256=` (HMAC-SHA256 of the raw body with your endpoint's secret) when the endpoint has one, alongside `X-Dmly-Event` and `X-Dmly-Delivery` headers. Return any 2xx to acknowledge; a non-2xx is retried twice more, after roughly 10s then 60s (three attempts in total).
# Subscription plan changed
Source: https://docs.dmly.io/api-reference/agency-events/subscription-plan-changed
/api-reference/agency-openapi.json webhook subscription.changed
Sent to your endpoint when **Subscription (plan) changed** occurs (`subscription.changed`).
Signed as `X-Dmly-Signature: sha256=` (HMAC-SHA256 of the raw body with your endpoint's secret) when the endpoint has one, alongside `X-Dmly-Event` and `X-Dmly-Delivery` headers. Return any 2xx to acknowledge; a non-2xx is retried twice more, after roughly 10s then 60s (three attempts in total).
# Agency API overview
Source: https://docs.dmly.io/api-reference/agency-introduction
Provision sub-account workspaces, assign plans and add-ons, and subscribe to lifecycle events: the whitelabel reseller API.
The **Agency API** is for whitelabel resellers. It lets you manage your sub-accounts
programmatically: provision a customer workspace, assign it a plan, grant add-ons, suspend or
restore it, and subscribe to its lifecycle events. The agency console does most of this too;
creating a workspace is the exception, since the console has no create button and customers
normally arrive by signing up on your branded domain.
```bash theme={"dark"}
curl https://dash.dmly.io/api/agency/v1/workspaces \
-H "x-api-key: dmly_ag_xxxxxxxxxxxx"
```
This is a **separate surface** from the [workspace REST API](/api-reference/introduction). It has
its own base URL, its own key, and its own webhooks. An agency key manages your sub-accounts; a
workspace key acts *inside* one workspace. The two are not interchangeable.
## Base URL
All endpoints live under a single version prefix:
```
https://dash.dmly.io/api/agency/v1
```
## What you can manage
Provision a customer workspace with its owner, rename it, and suspend or restore access.
Assign a base plan to a sub-account and grant or revoke add-ons on top of it.
List your plan and add-on catalogue to discover the uuids the other endpoints take.
Register endpoints and subscribe to sub-account lifecycle events.
Let customers connect WhatsApp, Messenger, Instagram, TikTok and Telegram from inside your own app.
## How it works
One agency API key (`dmly_ag_…`) authenticates you and scopes every request to your own
sub-accounts.
Sub-account lifecycle and channel-connection events, signed and delivered to your endpoint.
## Conventions
* **Identifiers are UUIDs.** Sub-accounts, plans, add-ons and webhook endpoints are addressed by
their public `uuid`.
* **You only ever see your own.** Every request is scoped to the agency the key belongs to; an
unknown uuid, or one belonging to another agency, returns `404`.
* **Lists paginate** with `?per_page` and `?page`, default 25, capped at 100. `?limit` is not
accepted here (that alias exists only on the workspace REST API), and an unrecognised parameter
is ignored, so `?limit=100` quietly returns 25 rows. Only `GET /workspaces` is paginated; the
plan catalogue and webhook lists are short and return a plain `data` array with no `meta`.
* **Rate limit:** 120 requests per minute per agency key. See
[Authentication](/api-reference/agency-authentication#rate-limit).
* **JSON in, JSON out.** Send `Content-Type: application/json` on writes.
## Billing model
Sub-account subscriptions are **agency-billed**: assigning a plan or granting an add-on copies the
plan's limits onto the workspace directly. There is no customer Stripe checkout in this flow, and
changing a plan never un-suspends a locked sub-account. You settle with DMLY; how you bill your own
customers is up to you.
## Getting a key
Mint an agency key from the agency console under **Configuration → API**. See
[Authentication](/api-reference/agency-authentication). The whole surface can also be disabled
fleet-wide during an incident, in which case every endpoint returns `503` until it is switched back
on.
## Try it
Every endpoint page has a playground. Paste an agency key into the **Authorization** panel and you
can call the live API straight from these docs.
The playground calls the live API and **provisions real sub-accounts**. Creating a workspace makes
a real one that counts toward your agency's limits, so clean up anything you create while testing.
# List the agency's sellable plans and add-ons.
Source: https://docs.dmly.io/api-reference/agency-plans/list-the-agencys-sellable-plans-and-add-ons
/api-reference/agency-openapi.json get /plans
Returns all enabled plans the agency sells, in the agency's configured order (not paginated). Each entry carries uuid, name, type ("base" or "addon"), addon_kind and addon_quantity (non-null only for add-ons), currency, monthly_amount and yearly_amount. Use these uuids as the plan/add-on identifier in the workspace-create, subscription-change and add-on-grant calls.
# Change a sub-account's base plan.
Source: https://docs.dmly.io/api-reference/agency-subscriptions-and-add-ons/change-a-sub-accounts-base-plan
/api-reference/agency-openapi.json put /workspaces/{workspace}/subscription
Assigns a new base plan to the sub-account and copies its limits onto the workspace. `plan` is required and must be an enabled base plan belonging to the agency (otherwise 422). Returns the detailed workspace card (including `limits`); a suspended sub-account stays suspended.
# Grant an add-on to a sub-account.
Source: https://docs.dmly.io/api-reference/agency-subscriptions-and-add-ons/grant-an-add-on-to-a-sub-account
/api-reference/agency-openapi.json post /workspaces/{workspace}/addons
Grants an add-on (e.g. extra contacts / social account / teammate) on top of the base plan. `plan` is required and must be an enabled add-on plan belonging to the agency; `quantity` is optional (integer 1-1000, default 1). Idempotent per (workspace, add-on plan): a repeat grant SETS the quantity rather than stacking a second row. Returns `data` `{ uuid, name, kind, quantity, granted_total }` — 201 when newly created, 200 when an existing grant was updated.
# Revoke an agency-granted add-on.
Source: https://docs.dmly.io/api-reference/agency-subscriptions-and-add-ons/revoke-an-agency-granted-add-on
/api-reference/agency-openapi.json delete /workspaces/{workspace}/addons/{addon}
Revokes an agency-granted add-on (identified by its uuid), reverting the affected limit to the base plan and firing the addon.revoked webhook. Returns `{ "data": { "deleted": true } }`. 404s if the add-on is not an agency-granted add-on on this sub-account.
# Show a sub-account's subscription.
Source: https://docs.dmly.io/api-reference/agency-subscriptions-and-add-ons/show-a-sub-accounts-subscription
/api-reference/agency-openapi.json get /workspaces/{workspace}/subscription
Returns the sub-account's current subscription as `data`: `{ workspace (uuid), status ("active"|"suspended"), plan ({ uuid, name } or null), addons (array of granted add-ons), limits (the effective merged limits) }`.
# Create a webhook endpoint.
Source: https://docs.dmly.io/api-reference/agency-webhook-endpoints/create-a-webhook-endpoint
/api-reference/agency-openapi.json post /webhooks
Registers a webhook endpoint. `name` (max 100), `url` (http/https, max 2048, SSRF-guarded) and `events` (a non-empty array whose members must be valid event keys) are required; `active` is optional and defaults to true. Returns 201 with the webhook card plus the signing `secret` (shown this one time only).
# Delete a webhook endpoint.
Source: https://docs.dmly.io/api-reference/agency-webhook-endpoints/delete-a-webhook-endpoint
/api-reference/agency-openapi.json delete /webhooks/{webhook}
Deletes the webhook endpoint identified by its uuid. Returns `{ "data": { "deleted": true } }`. 404s if the uuid is not one of the agency's webhooks.
# List the agency's webhook endpoints.
Source: https://docs.dmly.io/api-reference/agency-webhook-endpoints/list-the-agencys-webhook-endpoints
/api-reference/agency-openapi.json get /webhooks
Returns the agency's webhook endpoints as `data` (an array of cards), newest first — not paginated. Each card is `{ uuid, name, url, events, active, has_secret, last_delivery_status, last_delivered_at, created_at }`; the signing secret is never included here.
# List the subscribable webhook events.
Source: https://docs.dmly.io/api-reference/agency-webhook-endpoints/list-the-subscribable-webhook-events
/api-reference/agency-openapi.json get /webhooks/events
Returns the event catalogue as `data`: an array of `{ event, label }` for the six sub-account lifecycle events (workspace.created, workspace.suspended, workspace.restored, subscription.changed, addon.granted, addon.revoked). These are the only values accepted in a webhook's `events` array.
# Retrieve a single webhook endpoint.
Source: https://docs.dmly.io/api-reference/agency-webhook-endpoints/retrieve-a-single-webhook-endpoint
/api-reference/agency-openapi.json get /webhooks/{webhook}
Returns the webhook card for the endpoint identified by its uuid (without the secret). 404s if the uuid is not one of the agency's webhooks.
# Rotate a webhook's signing secret.
Source: https://docs.dmly.io/api-reference/agency-webhook-endpoints/rotate-a-webhooks-signing-secret
/api-reference/agency-openapi.json post /webhooks/{webhook}/rotate-secret
Mints a new signing secret for the endpoint, invalidating the old one. Takes no request body. Returns the webhook card plus the new `secret` (shown this one time only).
# Send a test event to a webhook.
Source: https://docs.dmly.io/api-reference/agency-webhook-endpoints/send-a-test-event-to-a-webhook
/api-reference/agency-openapi.json post /webhooks/{webhook}/test
Queues a sample delivery so an integrator can verify their endpoint without waiting for a real event. `event` is optional and must be a valid event key (422 otherwise); it defaults to the webhook's first subscribed event, or workspace.created. The sample envelope carries "test": true. The response `data` is `{ queued: true, event }`.
# Update a webhook endpoint.
Source: https://docs.dmly.io/api-reference/agency-webhook-endpoints/update-a-webhook-endpoint
/api-reference/agency-openapi.json patch /webhooks/{webhook}
Updates the endpoint. Uses the same validation as create, so `name`, `url` and `events` must all be supplied (it is a full replace, not a partial patch); `active` is optional and left unchanged when omitted. Returns the updated webhook card (without the secret).
# Agency webhooks
Source: https://docs.dmly.io/api-reference/agency-webhooks
Subscribe to sub-account lifecycle events: DMLY posts a signed envelope to your endpoint as they happen.
Rather than polling, register an endpoint and DMLY will `POST` to it whenever one of your
sub-accounts changes: a workspace is created, suspended or restored, or a subscription or add-on
changes. These are **agency** webhooks, separate from the [workspace webhooks](/api-reference/webhooks)
that fire on activity *inside* a workspace.
## The events
There are ten: six sub-account lifecycle events and four Channel Connect events.
| Event | Fires when |
| - | - |
| `workspace.created` | A customer signs up on your branded domain, or you create a sub-account with `POST /workspaces` |
| `workspace.suspended` | A sub-account is suspended |
| `workspace.restored` | A suspended sub-account is restored |
| `subscription.changed` | A sub-account's base plan changes |
| `addon.granted` | An add-on is granted to a sub-account |
| `addon.revoked` | An add-on is revoked from a sub-account |
| `channel.connection.completed` | A [Channel Connect](/api-reference/agency-channel-connect) session connected a channel |
| `channel.connection.failed` | A Channel Connect session failed |
| `channel.connection.cancelled` | A Channel Connect session was declined at Meta or cancelled |
| `channel.connection.expired` | A Channel Connect session ran past its 30 minutes |
The suspend, restore, plan and add-on events fire whether the change was made in the agency
console or over the [Agency API](/api-reference/agency-introduction), so one subscription keeps you
in sync with both. `workspace.created` is the exception: the console has no create button, so it
fires only from a branded sign-up or from the API.
`workspace.created` fires only for a brand-new sub-account created by a branded sign-up or by
`POST /workspaces`. Two other ways a workspace can join your agency do not fire it: an existing
customer adding a second workspace from the workspace switcher, and your own workspace being moved
under your agency when you took a reseller plan. Both appear in your sub-accounts list without a
webhook, so if you must catch every workspace, reconcile periodically against `GET /workspaces`.
[Client workspaces](/agency/client-workspaces) covers all four arrival paths.
## Setting one up
You can manage webhook endpoints from the agency console, under **Reseller Dashboard → Webhooks**,
or over the API with the `/webhooks` endpoints in the sidebar. In the console each endpoint's row
carries **Test**, **Rotate secret**, **Edit** and **Remove**.
In the console, select **+ Add webhook**. Add your endpoint URL and subscribe to the events you
care about. The URL may be `http` or `https`; use `https`. Its host must be publicly reachable:
an internal or private address is refused.
A signing secret is generated for you and returned **once**, on creation. Store it; you use it
to verify every delivery. If you lose it, select **Rotate secret** on the endpoint's row to get
a new one, which invalidates the old one.
Use the test event to confirm your endpoint is reachable and your signature check works before
relying on it. In the reseller dashboard, select **Test** on the endpoint's row. A test payload
carries an extra `"test": true`.
## The payload
Every delivery has the same envelope. Only `data` varies by event:
```json theme={"dark"}
{
"id": "9f1c4b6e-3a2d-4f8b-9c1e-2a7d5e8b0c31",
"event": "workspace.created",
"occurred_at": "2026-07-14T09:30:00+00:00",
"agency_id": 7,
"data": {
"workspace": {
"uuid": "11111111-1111-1111-1111-111111111111",
"name": "Acme Fitness",
"status": "active",
"plan": { "uuid": "22222222-2222-2222-2222-222222222222", "name": "Starter" }
}
}
}
```
The `subscription.changed` payload adds the new `plan`; `addon.granted` and `addon.revoked` add an
`addon` object. The four `channel.connection.*` events carry the connection itself as `data`, and
are sent within about a minute of the session ending. Each event page in the sidebar shows its
exact payload.
## Headers
| Header | Value |
| - | - |
| `X-Dmly-Event` | The event, e.g. `workspace.created` |
| `X-Dmly-Delivery` | Id for this delivery: the envelope `id`, stable across retries |
| `X-Dmly-Signature` | `sha256=`, sent whenever the endpoint has a signing secret |
| `User-Agent` | `Dmly-Agency-Webhooks/1.0` |
## Verifying a delivery
The signature is an HMAC-SHA256 of the **raw request body**, keyed by your endpoint's secret.
Compute it over the bytes exactly as received; parsing and re-serialising the JSON first changes
the bytes, and the signature will never match.
```python theme={"dark"}
import hmac, hashlib
def verify(raw_body: bytes, header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, header)
```
Always compare with a constant-time function (`hmac.compare_digest`, `crypto.timingSafeEqual`). A
plain `==` leaks timing information that can be used to forge a signature.
## Responding
Return any `2xx` to acknowledge. A `4xx` or `5xx`, a connection error or a timeout is a failure and
is retried twice more, after roughly **10s** then **60s** (three attempts in total). After the third
the delivery is marked failed and not retried again.
Redirects are not followed. A `3xx` answer, such as an `http` to `https` or trailing-slash
redirect, is recorded as a success and never retried, yet the event never reaches your handler.
Register the final URL your endpoint answers on.
Nothing is lost silently: every attempt is recorded. In the reseller dashboard, open **Webhooks**
and look under **Recent deliveries**, which lists the last 30 deliveries across all your endpoints
with columns **Webhook**, **Event**, **Result** and **When**. **Result** shows the outcome and the
HTTP status your endpoint returned, for example `error · 500`. The section only appears once there
has been at least one delivery. Each endpoint's row also shows its most recent outcome inline, as
`last: success` or `last: error`.
One case is never retried. DMLY re-checks your endpoint's host at delivery time, because DNS can
change between saving the endpoint and sending to it. If that check fails, nothing is sent: the
delivery shows in **Recent deliveries** as an error with no HTTP status, and no further attempt is
made.
Deliveries are at-least-once: a timeout on your side can produce a retry for an event you already
handled. Deduplicate on the envelope's `id` (the same value as the `X-Dmly-Delivery` header),
which is stable across retries.
## Events
Each event is documented with its full payload in the sidebar under this section; the list is
generated from the API itself, so it is never out of date.
# List the agency's sub-accounts.
Source: https://docs.dmly.io/api-reference/agency-workspaces/list-the-agencys-sub-accounts
/api-reference/agency-openapi.json get /workspaces
Returns a paginated list of the agency's sub-account workspace cards, newest first. Supports an optional name search. The response has `data` (an array of cards) plus a `meta` block with current_page, last_page, per_page and total.
# Provision a sub-account workspace.
Source: https://docs.dmly.io/api-reference/agency-workspaces/provision-a-sub-account-workspace
/api-reference/agency-openapi.json post /workspaces
Creates an owner user plus a workspace under the agency, on the supplied base-plan uuid or the agency's default plan (its limits are copied onto the workspace). name (the owner's name), business_name (becomes the workspace name), email (must be unique across users) and password (min 8) are required; plan is optional and must be a base plan belonging to the agency. Returns 201 with the workspace card plus an `owner` object (`{ name, email }`). Returns 422 if the agency is at its sub-account limit or the plan is not the agency's.
# Rename a sub-account.
Source: https://docs.dmly.io/api-reference/agency-workspaces/rename-a-sub-account
/api-reference/agency-openapi.json patch /workspaces/{workspace}
Renames the sub-account. Only `name` is accepted, and it is required (string, max 100). Returns the updated workspace card.
# Restore a suspended sub-account.
Source: https://docs.dmly.io/api-reference/agency-workspaces/restore-a-suspended-sub-account
/api-reference/agency-openapi.json post /workspaces/{workspace}/restore
Unlocks a previously suspended sub-account. Takes no request body. Returns the workspace card with status "active". Fires the workspace.restored webhook.
# Retrieve a single sub-account.
Source: https://docs.dmly.io/api-reference/agency-workspaces/retrieve-a-single-sub-account
/api-reference/agency-openapi.json get /workspaces/{workspace}
Returns the workspace card for the sub-account identified by its uuid, including the effective `limits` object. 404s if the uuid is not one of the agency's sub-accounts.
# Suspend a sub-account.
Source: https://docs.dmly.io/api-reference/agency-workspaces/suspend-a-sub-account
/api-reference/agency-openapi.json post /workspaces/{workspace}/suspend
Locks the sub-account so its team can no longer sign in. Takes no request body. Returns the workspace card with status "suspended". Fires the workspace.suspended webhook.
# Book a 1:1 appointment.
Source: https://docs.dmly.io/api-reference/appointments/book-a-1:1-appointment
/api-reference/openapi.json post /appointments
Re-validates the slot under a lock (no double-booking), then does one of two things depending on the service. A free or pay-after service is booked and confirmed: the calendar event is pushed, the confirmation message is sent, reminders are seeded, and booking.created + appointment_booked fire. A service set to Pay before booking is NOT confirmed: the slot is held, status comes back as pending_payment, and the 201 body carries payment_required true plus payment_url, a gateway checkout link to give the client. That hold confirms itself once payment succeeds, which is when the confirmation, reminders and calendar event happen, and appointment_confirmed fires alongside appointment_booked. If the slot is gone responds 422. The datetime field is parsed in timezone (default UTC). With no staff_id, an eligible staff member is auto-assigned.
# Cancel an appointment.
Source: https://docs.dmly.io/api-reference/appointments/cancel-an-appointment
/api-reference/openapi.json post /appointments/{booking}/cancel
Cancels the booking: deletes the calendar event, skips pending reminders, messages the contact and fires booking.cancelled + appointment_cancelled. Returns {"cancelled": true}, or false if the booking was already terminal.
# Get one appointment.
Source: https://docs.dmly.io/api-reference/appointments/get-one-appointment
/api-reference/openapi.json get /appointments/{booking}
Returns a single 1:1 appointment by its uuid, with the embedded service, staff and contact.
# List 1:1 appointments (paginated).
Source: https://docs.dmly.io/api-reference/appointments/list-1:1-appointments-paginated
/api-reference/openapi.json get /appointments
Returns confirmed/pending/completed/cancelled/no_show 1:1 bookings, newest scheduled first. Class-seat bookings are excluded. Filter by service, staff, contact (all uuids), status, and a scheduled_at window (from/to).
# List open booking slots for a service.
Source: https://docs.dmly.io/api-reference/appointments/list-open-booking-slots-for-a-service
/api-reference/openapi.json get /appointments/slots
Computes bookable slot start instants for a service over a date window, honouring working hours, min-notice/horizon, buffers, calendar busy time and existing bookings. The window runs from the start of `date` (in `timezone`) for `days` days. Returns {"slots": [iso8601 utc, ...]} (max 100).
# Mark an appointment completed or no-show.
Source: https://docs.dmly.io/api-reference/appointments/mark-an-appointment-completed-or-no-show
/api-reference/openapi.json put /appointments/{booking}/status
Transitions a non-terminal appointment to a terminal status. `completed` fires appointment_completed and awards loyalty; `no_show` fires appointment_no_show. Responds 422 if the appointment is already terminal.
# Reschedule an appointment.
Source: https://docs.dmly.io/api-reference/appointments/reschedule-an-appointment
/api-reference/openapi.json post /appointments/{booking}/reschedule
Re-validates the new slot, moves the booking, patches the calendar event, reseeds reminders and notifies the contact, firing booking.rescheduled + appointment_rescheduled. Responds 422 if the booking is terminal or the new slot is taken. `datetime` is parsed in `timezone` (default UTC).
# Authentication
Source: https://docs.dmly.io/api-reference/authentication
Authenticate with a workspace API key, sent as the x-api-key header.
Every request carries a **workspace API key**. The key does two jobs: it authenticates the
caller, and it selects the workspace, which is why no endpoint takes a workspace id.
## Sending the key
Send it as the `x-api-key` header:
```bash theme={"dark"}
curl https://dash.dmly.io/api/v1/contacts \
-H "x-api-key: dmly_xxxxxxxxxxxx"
```
A bearer token is also accepted, if that suits your HTTP client better:
```bash theme={"dark"}
curl https://dash.dmly.io/api/v1/contacts \
-H "Authorization: Bearer dmly_xxxxxxxxxxxx"
```
Both are equivalent. If both are present, `x-api-key` wins.
## Creating a key
In DMLY, go to **Configurations → Integrations** and select the **REST API** tile.
Give it a name describing what will use it: "Zapier", "Website form", "n8n". The name
is how you will recognise it later when revoking. Then choose its **REST API access**:
**Full access**, **Only the areas I choose** (each area set to read, or read and change),
or no REST access. The dialog also asks for its **AI assistant access**.
The key is shown **once**. DMLY stores only a SHA-256 hash of it, so it cannot be
retrieved or emailed to you afterwards.
A key made with **Full access** can reach everything in its workspace (so can any key made
before permissions existed). Give a key only the areas it needs. Treat it like a password: keep
it server-side, never commit it, and never put it in browser or mobile app code where a
user can read it.
## Rotating and revoking
Create the replacement key first, deploy it, then revoke the old one. Revocation takes
effect immediately and any caller still using the old key starts getting `401`.
DMLY stamps `last_used_at` on every authenticated request, so you can tell whether a key is
still in use before retiring it.
## When authentication fails
| Status | Body | Meaning |
| - | - | - |
| `401` | `API key missing. Send it as the "x-api-key" header or a Bearer token.` | No key on the request |
| `401` | `Invalid API key.` | Unknown or revoked key |
| `401` | `The workspace for this API key no longer exists.` | The workspace was deleted |
| `403` | `This workspace has been suspended.` | The workspace this key belongs to has been locked |
| `403` | `This workspace has been suspended by its provider.` | The workspace is an agency sub-account and its agency is no longer active |
| `403` | `This workspace has no active subscription.` | The subscription or trial has ended. Paying again restores access |
| `403` | `This API key does not have the ":" permission. Create a key that includes it.` | The key was not given that area. The body also has a `required_scope` field |
| `403` | `This API key does not have full API access, which this endpoint requires.` | The endpoint needs a key with **Full access** |
The `403`s are authorisation, not authentication: the key is real, but either the workspace behind
it cannot use the API or the key lacks a permission. A workspace that is merely past due keeps full
API access, because a grace period still counts as paying. Once a subscription or trial has actually
ended, every request fails with `403` `This workspace has no active subscription.`, reads included,
until it is paid again.
See [Errors](/api-reference/errors) for the full list.
# Activate an automation.
Source: https://docs.dmly.io/api-reference/automations/activate-an-automation
/api-reference/openapi.json post /automations/{automation}/activate
Sets the automation's status to active so it begins running on its trigger. Identified by uuid. Returns the updated automation. No request body is required.
# List automations in the workspace.
Source: https://docs.dmly.io/api-reference/automations/list-automations-in-the-workspace
/api-reference/openapi.json get /automations
Returns a paginated list of the workspace's Salesbot automations (flows and quick automations), newest first. Building or editing flows is out of scope for the API; this endpoint is read-only. Supports filtering by status, trigger type, builder, and the connected channel.
# Pause an automation.
Source: https://docs.dmly.io/api-reference/automations/pause-an-automation
/api-reference/openapi.json post /automations/{automation}/pause
Sets the automation's status to paused so it stops running until reactivated. Identified by uuid. Returns the updated automation. No request body is required.
# Retrieve a single automation.
Source: https://docs.dmly.io/api-reference/automations/retrieve-a-single-automation
/api-reference/openapi.json get /automations/{automation}
Returns one automation by its uuid. Responds 404 if no automation with that uuid exists in the current workspace.
# Approve a held broadcast.
Source: https://docs.dmly.io/api-reference/broadcasts/approve-a-held-broadcast
/api-reference/openapi.json post /broadcasts/{broadcast}/approve
Releases a broadcast awaiting approval: clears requires_approval, records the acting API key's user as approver with approved_at=now(), and marks the broadcast Scheduled so the DispatchDueBroadcasts cron can dispatch it. Returns the updated broadcast.
# Create a broadcast.
Source: https://docs.dmly.io/api-reference/broadcasts/create-a-broadcast
/api-reference/openapi.json post /broadcasts
Creates a broadcast on the given channel. Status is Draft unless 'schedule' is true (then Scheduled). Scheduling has two modes, set by timezone_mode. With 'fixed' (the default) scheduled_at is the absolute instant every recipient is sent at. With 'contact' you send scheduled_local_at as a naive wall clock such as 2026-07-01 09:00:00, and every recipient receives it at that wall clock in their OWN timezone: scheduled_local_at is then required (422 without it), any scheduled_at you send alongside it is ignored and overwritten, and because the send is anchored at UTC+14 and released in waves as each zone reaches the time, it can take just over a day to finish. The timezone fields do not schedule anything on their own: without 'schedule' true the broadcast is still created as a Draft, and a scheduled broadcast with neither scheduled_at nor scheduled_local_at sends on the next sweep. For a retarget campaign, set type=retarget with source_broadcast_id (a prior broadcast uuid) and retarget_engagement; otherwise the audience defines a contact segment. The example carries both scheduling fields so you can see the format of each, but scheduled_local_at is only read when timezone_mode is 'contact'. When the message's template has a document, image or video header, set message.header.url to a public https link to the file (tokens such as {{first_name}} resolve per recipient; message.header.filename names a document); for a text header with a variable, set message.header.parameters. Returns 201 with the created broadcast, which echoes timezone_mode and scheduled_local_at. Links in message.text, and in a template's parameters, are swapped for a short tracked link per recipient so clicks_count can count who clicked; set message.track_clicks to false to send them unchanged. A link written into the approved template itself, or a fixed template button, cannot be tracked. A WhatsApp template broadcast respects the number's Meta messaging limit (how many different people it may message with a template in a rolling 24 hours): recipients beyond what the limit has left are held and sent in later waves as the allowance frees, so a large broadcast can stay in status sending for more than a day.
# Delete a broadcast.
Source: https://docs.dmly.io/api-reference/broadcasts/delete-a-broadcast
/api-reference/openapi.json delete /broadcasts/{broadcast}
Permanently deletes the broadcast identified by its uuid. Returns {"deleted": true}.
# List a broadcast's recipients.
Source: https://docs.dmly.io/api-reference/broadcasts/list-a-broadcasts-recipients
/api-reference/openapi.json get /broadcasts/{broadcast}/recipients
Returns a paginated list of per-contact delivery rows for the broadcast (newest first), each with delivery status, provider message id, error payload and the embedded contact. Optionally filter by recipient status.
# List broadcasts in the workspace.
Source: https://docs.dmly.io/api-reference/broadcasts/list-broadcasts-in-the-workspace
/api-reference/openapi.json get /broadcasts
Returns a paginated list of broadcasts (newest first), each with channel and source-broadcast embeds. Optionally filter by status or by channel uuid.
# Retrieve a single broadcast.
Source: https://docs.dmly.io/api-reference/broadcasts/retrieve-a-single-broadcast
/api-reference/openapi.json get /broadcasts/{broadcast}
Returns the broadcast identified by its uuid, with channel and source-broadcast embeds and its aggregate counters: sent_count, delivered_count, opened_count (read), replies_count, clicks_count and failed_count. clicks_count is how many recipients clicked a tracked link in the broadcast, each counted once however often they clicked. Delivered and read are reported on WhatsApp, SMS, Messenger and Instagram (Instagram reports a read only, which also counts as delivered). A recipient later reported as failed moves out of sent_count (and delivered_count and opened_count if it had reached them) into failed_count, so the buckets never overlap. partially_failed is true when the broadcast finished (status sent) but at least one recipient failed.
# Send a broadcast now.
Source: https://docs.dmly.io/api-reference/broadcasts/send-a-broadcast-now
/api-reference/openapi.json post /broadcasts/{broadcast}/send
Marks the broadcast as Scheduled with scheduled_at=now(), so the DispatchDueBroadcasts cron dispatches it on its next run. The queue is never invoked directly. Returns the updated broadcast.
# Update a broadcast (PUT or PATCH).
Source: https://docs.dmly.io/api-reference/broadcasts/update-a-broadcast-put-or-patch
/api-reference/openapi.json put /broadcasts/{broadcast}
Updates the broadcast's name, audience, message, schedule, retarget config or approval flag. Re-requiring approval voids any prior sign-off. A broadcast cannot retarget itself (422). All fields are optional. The schedule is only re-derived when the request carries at least one of schedule, scheduled_at, timezone_mode or scheduled_local_at, so a partial update that omits all four leaves the existing schedule and timezone mode untouched. The two modes are the same as on create: 'fixed' sends every recipient at the scheduled_at instant, 'contact' sends each recipient at the scheduled_local_at wall clock in their own timezone (required in that mode, and it overrides any scheduled_at sent with it).
# List connected messaging channels.
Source: https://docs.dmly.io/api-reference/channels/list-connected-messaging-channels
/api-reference/openapi.json get /channels
Returns a paginated list of the workspace's connected messaging accounts (WhatsApp, Facebook, Instagram, SMS, Telegram, Live Chat, TikTok, etc.). Channels are connected via OAuth in the dashboard, so this endpoint is read-only. Supports keyword, platform, and status filtering. Results are ordered by name.
# Retrieve a single connected channel.
Source: https://docs.dmly.io/api-reference/channels/retrieve-a-single-connected-channel
/api-reference/openapi.json get /channels/{channel}
Returns a single connected messaging channel by its public id (uuid). Responds 404 if no matching channel exists in the current workspace.
# Book a contact into a session.
Source: https://docs.dmly.io/api-reference/classes/book-a-contact-into-a-session
/api-reference/openapi.json post /classes/{session}/participants
Books a contact (by uuid) into the session via the class booking service, reserving a seat under a capacity lock or — when the class is full and waitlisting is enabled — a waitlist place. Returns the created participant (201). If the booking cannot proceed (e.g. class_full, already_booked, not_bookable) responds 422 with an error reason.
# Cancel a participant's place.
Source: https://docs.dmly.io/api-reference/classes/cancel-a-participants-place
/api-reference/openapi.json delete /classes/{session}/participants/{participant}
Cancels a participant's place by its uuid via the class booking service. Freeing a confirmed seat returns it to the pool and promotes the next waitlisted contact. Returns {"deleted": true}.
# Create a group-class session.
Source: https://docs.dmly.io/api-reference/classes/create-a-group-class-session
/api-reference/openapi.json post /classes
Creates one occurrence of a class Service: a fixed time window with a seat pool. service_id is required (Service uuid); staff_id is the optional host (staff uuid). If ends_at is omitted it is derived from the Service duration. capacity defaults to the Service capacity and waitlist_enabled defaults to the Service setting when not supplied. Returns the created session (201).
# Delete a group-class session.
Source: https://docs.dmly.io/api-reference/classes/delete-a-group-class-session
/api-reference/openapi.json delete /classes/{session}
Deletes a group-class session by its uuid. Returns {"deleted": true}.
# List a session's participants.
Source: https://docs.dmly.io/api-reference/classes/list-a-sessions-participants
/api-reference/openapi.json get /classes/{session}/participants
Returns a paginated list of the session's participants — confirmed seats first, then the waitlist in position order. Each participant embeds its contact via the shared contact resource.
# List group-class sessions.
Source: https://docs.dmly.io/api-reference/classes/list-group-class-sessions
/api-reference/openapi.json get /classes
Returns a paginated list of group-class sessions (one occurrence of a class Service, each with a seat pool), ordered by start time. Scoped automatically to the authenticated workspace. The Service and host staff are embedded as lightweight objects and a participants_count is included.
# Retrieve a single group-class session.
Source: https://docs.dmly.io/api-reference/classes/retrieve-a-single-group-class-session
/api-reference/openapi.json get /classes/{session}
Returns one group-class session by its uuid, with the embedded Service, host staff and participants_count.
# Update a group-class session.
Source: https://docs.dmly.io/api-reference/classes/update-a-group-class-session
/api-reference/openapi.json put /classes/{session}
Updates a group-class session by its uuid. All fields are optional; only supplied fields change. Accepts staff_id (uuid or null), starts_at, ends_at, capacity, location, waitlist_enabled, timezone and status (scheduled/cancelled/completed). PATCH is also accepted.
# Add a note to a contact
Source: https://docs.dmly.io/api-reference/contacts/add-a-note-to-a-contact
/api-reference/openapi.json post /contacts/{contact}/notes
Appends a timeline note to the contact.
# Add tags to a contact
Source: https://docs.dmly.io/api-reference/contacts/add-tags-to-a-contact
/api-reference/openapi.json post /contacts/{contact}/tags
Adds tags by name (creating any that don't exist) without removing existing ones. Fires contact.tagged per newly-attached tag.
# Create a contact
Source: https://docs.dmly.io/api-reference/contacts/create-a-contact
/api-reference/openapi.json post /contacts
Creates a contact. If a channel uuid is supplied, a channel identity is attached. Fires the contact.created webhook. Send consent=true only when the person agreed to receive marketing messages: it is recorded in the contact's consent history with consent_text (the words they were shown) and consent_page_url. Omit it and no consent is recorded.
# Delete a contact
Source: https://docs.dmly.io/api-reference/contacts/delete-a-contact
/api-reference/openapi.json delete /contacts/{contact}
Deletes a contact and cascades its identities, messages, notes and tag links.
# Download all data held about a contact
Source: https://docs.dmly.io/api-reference/contacts/download-all-data-held-about-a-contact
/api-reference/openapi.json get /contacts/{contact}/data-export
Returns a ZIP archive of every record held about one person, the same archive as Download all data on the contact's Privacy tab: profile.json, one JSON file per kind of record (messages, notes, bookings, invoices, payments, consent history and more), a manifest.json with row counts and a README.txt describing each file. Use it to answer a data-access request. Stored files are listed with their links rather than embedded, and credentials such as access tokens are replaced with [withheld]. Needs the privacy:read permission (contacts:read is not enough, because the archive spans messages and finance). If the key was created by a team member, it is refused with 403 when that member cannot export contacts or would see the phone number or email masked, or has left the workspace. Limited to 10 exports a minute per key (429 with Retry-After), and every export is written to the workspace audit log.
# Get a contact
Source: https://docs.dmly.io/api-reference/contacts/get-a-contact
/api-reference/openapi.json get /contacts/{contact}
Returns a single contact by its id, with tags and pipeline stage.
# List contacts
Source: https://docs.dmly.io/api-reference/contacts/list-contacts
/api-reference/openapi.json get /contacts
Paginated list of contacts (DMLY's unified person record). A 'client' is simply a contact with lifecycle_stage=client.
# Remove tags from a contact
Source: https://docs.dmly.io/api-reference/contacts/remove-tags-from-a-contact
/api-reference/openapi.json delete /contacts/{contact}/tags
Detaches the named tags from the contact. Fires contact.untagged per removed tag.
# Set a contact's lifecycle stage
Source: https://docs.dmly.io/api-reference/contacts/set-a-contacts-lifecycle-stage
/api-reference/openapi.json put /contacts/{contact}/lifecycle
Moves the contact along the CRM lifecycle. 'client' converts via the lifecycle service (creates a client profile); 'lead' downgrades (blocked if commercial records exist).
# Set a contact's pipeline stage
Source: https://docs.dmly.io/api-reference/contacts/set-a-contacts-pipeline-stage
/api-reference/openapi.json put /contacts/{contact}/stage
Moves the contact to a pipeline stage (or clears it with null). Fires contact.stage_changed.
# Update a contact
Source: https://docs.dmly.io/api-reference/contacts/update-a-contact
/api-reference/openapi.json put /contacts/{contact}
Updates a contact's fields. Fires the contact.updated webhook.
# List conversations (the unified inbox).
Source: https://docs.dmly.io/api-reference/conversations-&-messages/list-conversations-the-unified-inbox
/api-reference/openapi.json get /conversations
Returns the workspace's conversations — Contacts that have an inbox presence — ordered by most recent activity (last_interaction_at, then id). Each item carries the conversation rollups: name, platform, last_message preview, unread_count, inbox_status and last_interaction_at. The conversation id is the contact uuid; fetch the thread via GET conversations/{contact}/messages and reply via POST messages. Supports keyword, triage-status, unread and platform filters, and standard pagination.
# List the messages in one conversation thread.
Source: https://docs.dmly.io/api-reference/conversations-&-messages/list-the-messages-in-one-conversation-thread
/api-reference/openapi.json get /conversations/{contact}/messages
Returns the messages for the conversation identified by the contact uuid in the path, oldest-first (ascending id) and paginated. Each message exposes id (message uuid), direction (inbound/outbound), type (text/image/video/audio/file/template/system), kind, body, media, status (sent/delivered/read/failed), provider_message_id and created_at. Returns 404 if no contact with that uuid exists in the workspace.
# Send a message into a conversation.
Source: https://docs.dmly.io/api-reference/conversations-&-messages/send-a-message-into-a-conversation
/api-reference/openapi.json post /messages
Sends an outbound message to the contact identified by contact_id (a contact uuid), routed through the same InboxService used by the dashboard composer — so it is delivered on the contact's active channel, recorded on the thread, broadcast to open inboxes, and fires the standard message.sent / message.failed webhooks. type defaults to text; for type=template on WhatsApp pass template_id (a WaTemplate uuid) and parameters (the ordered {{n}} values). On success returns the created message (201). Any send failure (closed messaging window, missing template, provider error) returns 422 with a message.
# Errors
Source: https://docs.dmly.io/api-reference/errors
Conventional HTTP status codes, with a consistent JSON error body.
DMLY uses conventional HTTP status codes. Anything in the `2xx` range succeeded; `4xx` means the request was wrong; `5xx` means something broke on our side.
Every error returns the same envelope (a missing-permission `403` adds a `required_scope` field):
```json theme={"dark"}
{ "message": "No query results for model [Contact]." }
```
Validation errors add a per-field breakdown:
```json theme={"dark"}
{
"message": "The name field is required.",
"errors": {
"name": ["The name field is required."],
"email": ["The email must be a valid email address."]
}
}
```
## Status codes
| Code | Meaning | What to do |
| - | - | - |
| `401` | Missing, invalid, or revoked API key, or its workspace was deleted | Check the key. Do not retry as-is |
| `400` | The `Idempotency-Key` header is empty or longer than 255 characters | Fix the header |
| `403` | The key lacks the permission for this area, the workspace has no active subscription, or it has been suspended | See [A note on 403](#a-note-on-403) |
| `409` | A request with the same `Idempotency-Key` is still being processed | Wait and retry, honour `Retry-After` |
| `404` | No such resource **in this workspace** | Check the id, and that the key belongs to the right workspace |
| `422` | Validation failed, or an `Idempotency-Key` was reused for a different request | Read `errors` and fix the request, or use a new key |
| `429` | [Rate limit](/api-reference/rate-limits) exceeded | Back off, honour `Retry-After` |
| `5xx` | Something broke on our side | Retry with backoff; if it persists, get in touch |
## A note on 403
A `403` has one of three causes:
* **The key lacks a permission.** The body names it, for example
`{"message": "This API key does not have the \"contacts:write\" permission. Create a key that includes it.", "required_scope": "contacts:write"}`.
Create a key that includes that area. An endpoint outside every area needs a key with **Full access**.
* **The subscription has ended.** The body is `This workspace has no active subscription.` and it
applies to every request, reads included. Paying again restores access. A workspace that is only
past due keeps working over the API.
* **The workspace is suspended**: either it was locked, or it is an agency sub-account whose agency
is no longer active. Whoever suspended it has to lift it: the agency for a sub-account, otherwise
get in touch.
## A note on 404
The API is workspace-scoped by the key. A resource that exists but belongs to a *different* workspace returns `404`, not `403`; from your key's point of view it genuinely does not exist. If you are certain an id is valid and still get `404`, you are almost always using a key from the wrong workspace.
## Retrying safely
`GET` and `DELETE` are idempotent, so retry freely.
`POST` is **not**, unless you send an `Idempotency-Key` header. A create that times out may still have succeeded, so a blind retry can produce a duplicate.
Send `Idempotency-Key` with a unique value (1 to 255 characters) on each write. A repeat with the same key, method, path, query and body within 24 hours returns the first answer again, with the header `Idempotent-Replayed: true`, instead of creating a second record. A repeat while the first is still running gets `409` with `Retry-After: 2`. Reusing a key for a different request gets `422`. Answers of `408`, `409`, `425`, `429` and `5xx` are not stored, so retrying one runs the request again.
Without the header, search for the record before retrying a `POST`, or key off something unique (a contact's phone number).
Retry `409`, `429` and `5xx` with backoff. Never retry `401`, `403`, or `422` unchanged; the same request will fail identically every time.
# Create a draft invoice from line items.
Source: https://docs.dmly.io/api-reference/finance-—-invoices/create-a-draft-invoice-from-line-items
/api-reference/openapi.json post /invoices
Creates a draft invoice from snapshotted line items via InvoiceService::createFromLineItems. contact_id is the optional contact uuid; supplying it converts the contact to a client. Each line item snapshots name/unit_price/tax at invoice time. Totals are computed server-side. Returns 201 with the created invoice.
# List invoices for the workspace.
Source: https://docs.dmly.io/api-reference/finance-—-invoices/list-invoices-for-the-workspace
/api-reference/openapi.json get /invoices
Returns a paginated list of invoices, most recent first, each with its embedded contact and inline line items. Filter by status, contact (uuid) and issue-date range.
# Record a manual payment against an invoice.
Source: https://docs.dmly.io/api-reference/finance-—-invoices/record-a-manual-payment-against-an-invoice
/api-reference/openapi.json post /invoices/{invoice}/payments
Records a manual payment for the invoice's contact via PaymentService::recordManual (provider=manual, advanced to succeeded) then recomputes the invoice's paid/due/status via InvoiceService::recordPayment. Fails with 422 if the invoice has no contact. Returns the updated invoice.
# Retrieve a single invoice.
Source: https://docs.dmly.io/api-reference/finance-—-invoices/retrieve-a-single-invoice
/api-reference/openapi.json get /invoices/{invoice}
Returns one invoice by its uuid, including the embedded contact and inline line items (name, unit_price, quantity, total).
# Send (finalise) an invoice.
Source: https://docs.dmly.io/api-reference/finance-—-invoices/send-finalise-an-invoice
/api-reference/openapi.json post /invoices/{invoice}/send
Finalises a draft invoice to 'sent' and posts the statement debit via InvoiceService::send. No-op on an already-void invoice. Returns the updated invoice.
# Void an invoice.
Source: https://docs.dmly.io/api-reference/finance-—-invoices/void-an-invoice
/api-reference/openapi.json post /invoices/{invoice}/void
Voids the invoice (never deletes) via InvoiceService::void, posting an offsetting statement credit for any still-open receivable. No-op on an already-void invoice. Returns the updated invoice.
# Cancel an order.
Source: https://docs.dmly.io/api-reference/finance-—-orders/cancel-an-order
/api-reference/openapi.json post /orders/{order}/cancel
Cancels an order via OrderService::cancel, restoring any product stock that was decremented on confirmation and clawing back loyalty earned. Returns the updated order.
# Confirm an order.
Source: https://docs.dmly.io/api-reference/finance-—-orders/confirm-an-order
/api-reference/openapi.json post /orders/{order}/confirm
Confirms a draft order via OrderService::confirm, moving it to confirmed and decrementing tracked product stock exactly once per line item. Cancelled or refunded orders are returned unchanged. Returns the updated order.
# Create a draft order from offering/custom selections.
Source: https://docs.dmly.io/api-reference/finance-—-orders/create-a-draft-order-from-offeringcustom-selections
/api-reference/openapi.json post /orders
Builds a new draft order via OrderService::createFromOfferings. Each item is a selection: type service|product|plan resolves the offering by its uuid (catalogue price/tax, optionally overridden), or type custom for a free-form line. Prices, tax and totals are computed server-side; an optional coupon_code is validated and applied. Returns the created order with embedded contact and items. A 422 is returned if the items resolve to mixed currencies.
# List orders (paginated).
Source: https://docs.dmly.io/api-reference/finance-—-orders/list-orders-paginated
/api-reference/openapi.json get /orders
Returns the workspace's internal orders, newest first, with the embedded contact and snapshotted line items. Filterable by status and contact.
# Retrieve a single order.
Source: https://docs.dmly.io/api-reference/finance-—-orders/retrieve-a-single-order
/api-reference/openapi.json get /orders/{order}
Returns one order by its uuid, with the embedded contact and snapshotted line items.
# Create a discount coupon.
Source: https://docs.dmly.io/api-reference/finance-—-payments-&-coupons/create-a-discount-coupon
/api-reference/openapi.json post /coupons
Creates a coupon. The code is uppercased; a percent-type value is capped at 100%. Returns the created coupon (201).
# Delete a discount coupon.
Source: https://docs.dmly.io/api-reference/finance-—-payments-&-coupons/delete-a-discount-coupon
/api-reference/openapi.json delete /coupons/{coupon}
Deletes a coupon resolved by its uuid. Returns {"deleted": true}.
# List discount coupons.
Source: https://docs.dmly.io/api-reference/finance-—-payments-&-coupons/list-discount-coupons
/api-reference/openapi.json get /coupons
Returns a paginated list of discount coupons for the current workspace, newest first. Supports a free-text code search and an active filter.
# List payments in the unified ledger.
Source: https://docs.dmly.io/api-reference/finance-—-payments-&-coupons/list-payments-in-the-unified-ledger
/api-reference/openapi.json get /payments
Returns a paginated list of payments (manual + gateway) for the current workspace, newest first. Each payment embeds its contact and, when present, a lightweight invoice reference. Supports filtering by status, contact, and payment-date range.
# Record a manual payment for a contact.
Source: https://docs.dmly.io/api-reference/finance-—-payments-&-coupons/record-a-manual-payment-for-a-contact
/api-reference/openapi.json post /payments
Records a manual (non-gateway) payment via PaymentService::recordManual. The payment is advanced straight to succeeded through the same path as a gateway webhook, posting a statement credit, converting the contact to a client, and updating their balance/LTV. Returns the created payment (201).
# Refund a payment (full or partial).
Source: https://docs.dmly.io/api-reference/finance-—-payments-&-coupons/refund-a-payment-full-or-partial
/api-reference/openapi.json post /payments/{payment}/refund
Refunds a succeeded payment via PaymentService::refund. Posts an offsetting statement debit (never deletes the row), updates any linked invoice, and claws back loyalty earned. Omit amount for a full refund or supply a partial amount. Returns the updated payment.
# Retrieve a single coupon.
Source: https://docs.dmly.io/api-reference/finance-—-payments-&-coupons/retrieve-a-single-coupon
/api-reference/openapi.json get /coupons/{coupon}
Returns a single coupon resolved by its uuid.
# Retrieve a single payment.
Source: https://docs.dmly.io/api-reference/finance-—-payments-&-coupons/retrieve-a-single-payment
/api-reference/openapi.json get /payments/{payment}
Returns a single payment resolved by its uuid, with the embedded contact and invoice reference.
# Update a discount coupon (PUT or PATCH).
Source: https://docs.dmly.io/api-reference/finance-—-payments-&-coupons/update-a-discount-coupon-put-or-patch
/api-reference/openapi.json put /coupons/{coupon}
Updates a coupon resolved by its uuid. Accepts the same body as create; the code is uppercased and a percent-type value is capped at 100%. Returns the updated coupon.
# Cancel a subscription.
Source: https://docs.dmly.io/api-reference/finance-—-subscriptions/cancel-a-subscription
/api-reference/openapi.json post /subscriptions/{subscription}/cancel
Cancels the subscription (clearing its next billing date) if it is not already cancelled. Returns the updated subscription.
# Change a subscription's plan.
Source: https://docs.dmly.io/api-reference/finance-—-subscriptions/change-a-subscriptions-plan
/api-reference/openapi.json post /subscriptions/{subscription}/change-plan
Moves the subscription to a different plan (identified by its uuid). effective controls when the change lands: 'now' switches immediately, 'next_renewal' (the default) defers it to the next billing date. proration defaults to true and, when switching now, applies a credit or charge for the unused portion of the current period. Returns the updated subscription, or 422 if the change is not allowed.
# List subscriptions for the workspace.
Source: https://docs.dmly.io/api-reference/finance-—-subscriptions/list-subscriptions-for-the-workspace
/api-reference/openapi.json get /subscriptions
Returns a paginated list of the workspace's subscriptions, newest first. Filter by status and/or contact. Each item embeds its contact (full ContactResource) and a lightweight plan object.
# Pause an active subscription.
Source: https://docs.dmly.io/api-reference/finance-—-subscriptions/pause-an-active-subscription
/api-reference/openapi.json post /subscriptions/{subscription}/pause
Pauses the subscription if it is currently active (no-op otherwise). Returns the updated subscription.
# Resume a paused subscription.
Source: https://docs.dmly.io/api-reference/finance-—-subscriptions/resume-a-paused-subscription
/api-reference/openapi.json post /subscriptions/{subscription}/resume
Resumes the subscription if it is currently paused (no-op otherwise). Returns the updated subscription.
# Retrieve a single subscription.
Source: https://docs.dmly.io/api-reference/finance-—-subscriptions/retrieve-a-single-subscription
/api-reference/openapi.json get /subscriptions/{subscription}
Returns one subscription by its uuid, with the embedded contact and plan.
# Subscribe a contact to a plan.
Source: https://docs.dmly.io/api-reference/finance-—-subscriptions/subscribe-a-contact-to-a-plan
/api-reference/openapi.json post /subscriptions
Creates a subscription for a contact from a plan. Billing cadence, granted contents and (by default) price/currency are snapshotted from the plan; price and currency may be overridden. Credits are granted into the contact's credit ledger and the contact is converted to a client. Returns the created subscription (201).
# List gift cards.
Source: https://docs.dmly.io/api-reference/gift-cards/list-gift-cards
/api-reference/openapi.json get /gift-cards
Returns a paginated list of the workspace's gift cards, newest first. Filter by a code substring and/or status. Each item exposes the purchaser and recipient contact uuids when they are set.
# Redeem a gift card against an invoice.
Source: https://docs.dmly.io/api-reference/gift-cards/redeem-a-gift-card-against-an-invoice
/api-reference/openapi.json post /gift-cards/redeem
Draws a gift card (identified by its code) against an invoice (identified by its uuid), recording a gift_card payment that settles the invoice. An optional amount caps how much to draw; otherwise the lesser of the card balance and the invoice's amount due is applied. Responds with the redeemed amount, the card's remaining balance and the invoice's new amount due, or 422 if redemption fails.
# Retrieve a single gift card.
Source: https://docs.dmly.io/api-reference/gift-cards/retrieve-a-single-gift-card
/api-reference/openapi.json get /gift-cards/{giftCard}
Returns the gift card identified by its uuid, including its code, status, initial and remaining value, currency and the linked purchaser/recipient contact uuids.
# Sell or issue a gift card.
Source: https://docs.dmly.io/api-reference/gift-cards/sell-or-issue-a-gift-card
/api-reference/openapi.json post /gift-cards
Creates a gift card. By default the card is sold to a purchaser (required) and a purchase invoice is minted and sent. Pass comp=true to issue a complimentary card instead, with no purchaser and no billing. value and currency are required; a recipient and an expiry are optional. Returns the created gift card (201).
# Introduction
Source: https://docs.dmly.io/api-reference/introduction
The DMLY REST API: contacts, conversations, broadcasts, bookings, finance, webhooks and media.
The DMLY REST API gives you programmatic access to everything your workspace holds: the
CRM, the unified inbox, broadcasts and automations, appointments and classes, the
commercial catalogue, finance, and media.
```bash theme={"dark"}
curl https://dash.dmly.io/api/v1/contacts \
-H "x-api-key: dmly_xxxxxxxxxxxx"
```
## Base URL
All endpoints live under a single version prefix:
```
https://dash.dmly.io/api/v1
```
If you are on a whitelabel or agency domain, use your own host in place of
`dash.dmly.io`. The path is always `/api/v1`.
## How it works
One workspace API key authenticates you and selects the workspace.
Lists return `data`, `links` and `meta`.
Conventional status codes and a consistent error body.
60 requests per minute, per API key.
## Conventions
* **Identifiers are UUIDs.** Every resource is addressed by its public `uuid`, which the
API returns as `id`. Internal database ids are never exposed.
* **No workspace in the URL.** Your API key already determines the workspace, so paths look
like `/contacts/{contact}` rather than `/workspaces/{id}/contacts/{contact}`.
* **Single resources** return `{ "data": { … } }`.
* **JSON in, JSON out.** Send `Content-Type: application/json` on writes. The only
exception is uploading media (`POST /media`), which takes `multipart/form-data`.
## Events
Rather than polling, subscribe to any of the 83 [webhook topics](/api-reference/webhooks)
and DMLY will post to your endpoint as things happen: a message arrives, an invoice is
paid, an appointment is booked.
## Try it
Every endpoint page has a playground. Paste a workspace API key into the **Authorization**
panel and you can call your real workspace straight from these docs.
The playground calls the live API against real data. Create a test workspace before
experimenting with anything destructive.
# Delete a media item.
Source: https://docs.dmly.io/api-reference/media/delete-a-media-item
/api-reference/openapi.json delete /media/{media}
Detaches the media from any post versions that reference it, deletes the underlying files (including conversions) from storage, then removes the record. Returns {"deleted": true}.
# List media library items (paginated).
Source: https://docs.dmly.io/api-reference/media/list-media-library-items-paginated
/api-reference/openapi.json get /media
Returns the workspace's media library (images, videos, gifs), newest first. Workspace scoping is automatic. Supports an optional name search and standard pagination.
# Retrieve a single media item.
Source: https://docs.dmly.io/api-reference/media/retrieve-a-single-media-item
/api-reference/openapi.json get /media/{media}
Returns one media item resolved by its public id (uuid). 404 if it does not exist in the current workspace.
# Upload a file to the media library.
Source: https://docs.dmly.io/api-reference/media/upload-a-file-to-the-media-library
/api-reference/openapi.json post /media
Uploads a single file via multipart/form-data and creates a Media record. Reuses the dashboard upload pipeline (storage on the configured disk plus thumbnail/video conversions). Returns the created media item with HTTP 201. Send the request as multipart/form-data with a 'file' part (not JSON).
# Create a plan/package.
Source: https://docs.dmly.io/api-reference/offerings-—-products-&-plans/create-a-planpackage
/api-reference/openapi.json post /plans
Creates a plan via the OfferingService write seam (syncs line items and fires PlanCreated/PlanActivated events). For recurring plans, billing_interval defaults to month and interval_count to 1 when omitted. Each item references a service/product by uuid; credit items grant a credit pool. category_id is a ServiceCategory uuid. Returns 201 with the created plan.
# Create a product.
Source: https://docs.dmly.io/api-reference/offerings-—-products-&-plans/create-a-product
/api-reference/openapi.json post /products
Creates a product via the OfferingService write seam (records the stock ledger opening balance when tracking stock and fires the ProductCreated event). category_id, when supplied, is a ServiceCategory uuid. Returns 201 with the created product.
# Delete a plan/package.
Source: https://docs.dmly.io/api-reference/offerings-—-products-&-plans/delete-a-planpackage
/api-reference/openapi.json delete /plans/{plan}
Deletes a plan and its line items. Returns {deleted:true}.
# Delete a product.
Source: https://docs.dmly.io/api-reference/offerings-—-products-&-plans/delete-a-product
/api-reference/openapi.json delete /products/{product}
Deletes a product and its stock movements. A product bundled into a plan is deactivated instead of deleted to preserve referential integrity; in that case the response is {deleted:false} with an explanatory message.
# Get a single plan/package.
Source: https://docs.dmly.io/api-reference/offerings-—-products-&-plans/get-a-single-planpackage
/api-reference/openapi.json get /plans/{plan}
Returns one plan resolved by its uuid, with its category and granted line items (services, products, credits) embedded.
# Get a single product.
Source: https://docs.dmly.io/api-reference/offerings-—-products-&-plans/get-a-single-product
/api-reference/openapi.json get /products/{product}
Returns one product resolved by its uuid, with its category embedded.
# List plans/packages in the Offerings catalogue.
Source: https://docs.dmly.io/api-reference/offerings-—-products-&-plans/list-planspackages-in-the-offerings-catalogue
/api-reference/openapi.json get /plans
Returns a paginated list of plans (one-time packages or recurring memberships) ordered by sort_order then name, with category and granted line items embedded. Workspace-scoped automatically.
# List products in the Offerings catalogue.
Source: https://docs.dmly.io/api-reference/offerings-—-products-&-plans/list-products-in-the-offerings-catalogue
/api-reference/openapi.json get /products
Returns a paginated list of products (sellable goods) ordered by sort_order then name. Each product is workspace-scoped automatically. Supports keyword, active, low-stock and category filters.
# Update a plan/package (PUT or PATCH).
Source: https://docs.dmly.io/api-reference/offerings-—-products-&-plans/update-a-planpackage-put-or-patch
/api-reference/openapi.json put /plans/{plan}
Updates a plan via the OfferingService write seam. When items are supplied they fully replace the plan's existing line items. Fires PlanUpdated and PlanActivated/PlanDeactivated when the active flag changes. category_id is a ServiceCategory uuid.
# Update a product (PUT or PATCH).
Source: https://docs.dmly.io/api-reference/offerings-—-products-&-plans/update-a-product-put-or-patch
/api-reference/openapi.json put /products/{product}
Updates a product via the OfferingService write seam. When the product tracks stock and stock_quantity is supplied, the stock level is reset through the ledger; turning track_stock off clears the quantity. Fires the ProductUpdated event. category_id is a ServiceCategory uuid.
# Pagination
Source: https://docs.dmly.io/api-reference/pagination
List endpoints return data, links and meta. Page with ?page and size with ?per_page.
Every list endpoint is paginated. Responses have the same three-part shape:
```json theme={"dark"}
{
"data": [ { "id": "…", "name": "Ada Lovelace" } ],
"links": {
"first": "https://dash.dmly.io/api/v1/contacts?page=1",
"last": "https://dash.dmly.io/api/v1/contacts?page=9",
"prev": null,
"next": "https://dash.dmly.io/api/v1/contacts?page=2"
},
"meta": {
"current_page": 1,
"from": 1,
"to": 25,
"per_page": 25,
"last_page": 9,
"total": 213
}
}
```
## Parameters
| Parameter | Default | Notes |
| - | - | - |
| `page` | `1` | The page to fetch |
| `per_page` | `25` | Items per page. **Capped at 100** |
| `limit` | Same as `per_page` | Alias for `per_page` |
Asking for more than 100 is not an error; the value is silently clamped to 100. Read
`meta.per_page` if you need to know what you actually got.
```bash theme={"dark"}
curl "https://dash.dmly.io/api/v1/contacts?per_page=100&page=2" \
-H "x-api-key: dmly_xxxxxxxxxxxx"
```
## Walking every page
Follow `links.next` until it is `null`. That is more robust than incrementing `page`
yourself and comparing against `meta.last_page`, because it stays correct even if the
total shifts while you iterate.
```python theme={"dark"}
url = "https://dash.dmly.io/api/v1/contacts?per_page=100"
headers = {"x-api-key": "dmly_xxxxxxxxxxxx"}
while url:
r = requests.get(url, headers=headers).json()
for contact in r["data"]:
...
url = r["links"]["next"]
```
Use `per_page=100` for bulk reads. Each page costs one request against your
[rate limit](/api-reference/rate-limits), so larger pages mean fewer requests.
Paginating a list that is changing underneath you can skip or repeat records across
pages. For a consistent bulk export, narrow the query with a filter that will not move,
or accept that a small amount of drift is possible.
# Rate limits
Source: https://docs.dmly.io/api-reference/rate-limits
60 requests per minute on the workspace REST API, counted per API key; every key gets its own budget.
The workspace REST API allows **60 requests per minute per API key**.
Other DMLY API surfaces have their own budgets. The [reseller
API](/api-reference/agency-authentication) allows 120 per minute per agency key, and the [MCP
server](/mcp/integrating-clients) 120 per minute per key or OAuth connection. Each falls back to
60 per minute per IP address for a request with no credential it can resolve.
Each key gets its own budget. Two integrations calling from the same server (or the same
office network) don't compete, as long as they use separate keys. One more reason to
[create a key per integration](/api-reference/authentication#creating-a-key) rather than
sharing one everywhere.
Requests without a valid key (missing, mistyped, or revoked) share one 60/minute budget
per IP address instead. They fail with `401` regardless; the shared bucket just caps how
fast.
## Headers
Every response carries your current standing, so you rarely need to guess:
| Header | Meaning |
| - | - |
| `X-RateLimit-Limit` | Requests allowed per minute (`60`) |
| `X-RateLimit-Remaining` | Requests left in the current window |
| `Retry-After` | Seconds until the window resets. **Only on `429`** |
## When you exceed it
You get `429 Too Many Requests`, as JSON like every other error:
```json theme={"dark"}
{ "message": "Too many requests. Retry after 42 seconds." }
```
Wait the number of seconds in `Retry-After` (the message states the same figure), then
retry. Retrying sooner just burns another request and extends the problem.
```python theme={"dark"}
headers = {"x-api-key": "dmly_xxxxxxxxxxxx"}
r = requests.get(url, headers=headers)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", 60)))
r = requests.get(url, headers=headers)
```
## Staying under the limit
`?per_page=100` fetches the same data in a quarter of the requests that the default 25
would need. See [Pagination](/api-reference/pagination).
Polling for new messages every few seconds will exhaust the budget on its own.
[Subscribe to events](/api-reference/webhooks) and let DMLY call you.
Read `X-RateLimit-Remaining` and slow down as it approaches zero, rather than waiting to
be refused.
Ten parallel workers on one key fight over the same 60/minute. One worker, spacing
requests, finishes sooner, or give each worker its own key.
The budget belongs to the key, so a `429` you can't explain means something else is
calling with the **same key**: a second copy of your worker, a forgotten cron job, a
colleague who pasted it into another tool. Give each integration its own key and the
mystery disappears along with the shared budget.
# Create a saved contact segment.
Source: https://docs.dmly.io/api-reference/segments/create-a-saved-contact-segment
/api-reference/openapi.json post /segments
Creates a new contact filter. name is required (max 80). stage is an optional stage uuid. include_tags/exclude_tags are arrays of tag uuids (contacts must have ALL include tags and NONE of the exclude tags). min_days_inactive filters to contacts with no interaction for at least N days. conditions is the grouped rule tree: a top-level match of all or any, plus up to 20 groups, each with its own match and up to 30 rules. Groups combine by the top-level match and the rules inside a group by that group's match, which gives one level of OR of AND. A rule is field, op and value, plus value2 for a range and name for the custom-field key. The fields and the operators each accepts are: lifecycle_stage and source (equals, not_equals, contains, not_contains, has_value, is_empty); custom_field (equals, not_equals, contains, not_contains, has_value, is_empty, greater_than, less_than, gte, lte, with name set to the custom-field key); channel_reachable (reachable, not_reachable, with value a platform such as whatsapp); opt_in (opted_in, opted_out, value unused); spend, order_count, loyalty_points and credit_balance (equals, not_equals, greater_than, less_than, gte, lte, between, where between needs a numeric value2); last_purchase (within, older, never, with value a number of days); birthday_month (is, is_not, with value the month number); has_tag (has, not_has, with value a tag uuid). Rules are sanitised on save: a rule with an unrecognised field, an operator that field does not accept, a custom_field rule with no name, or a between rule without a numeric value2 is DROPPED rather than rejected, and the request still succeeds. If every rule is dropped the segment is saved with no conditions at all, so confirm the audience afterwards with the segment's contacts endpoint or contacts_count. conditions is combined with stage, include_tags, exclude_tags and min_days_inactive using AND, not instead of them: when moving a segment onto conditions, clear the flat fields in the same request or it will narrow by both. conditions is write-only, accepted here but never returned in a segment response.
# Delete a saved contact segment.
Source: https://docs.dmly.io/api-reference/segments/delete-a-saved-contact-segment
/api-reference/openapi.json delete /segments/{segment}
Deletes a segment by uuid. Returns {"deleted": true}. The underlying contacts are not affected.
# List saved contact segments.
Source: https://docs.dmly.io/api-reference/segments/list-saved-contact-segments
/api-reference/openapi.json get /segments
Returns a paginated list of the workspace's saved contact filters. Each segment includes its embedded stage, include/exclude tag uuids and recency threshold. contacts_count is NOT included by default on this endpoint: pass with_counts=1 to compute it. (A single segment fetched, created or updated always carries a live contacts_count.)
# List the contacts matching a segment.
Source: https://docs.dmly.io/api-reference/segments/list-the-contacts-matching-a-segment
/api-reference/openapi.json get /segments/{segment}/contacts
Returns a paginated list of the contacts that currently match the segment's filter criteria (stage + include/exclude tags + recency, ANDed with the grouped conditions tree if the segment has one), as full contact resources with their tags and stage loaded. Because conditions are never echoed back, this is the endpoint to check what a saved rule tree actually matches.
# Retrieve a single segment.
Source: https://docs.dmly.io/api-reference/segments/retrieve-a-single-segment
/api-reference/openapi.json get /segments/{segment}
Returns one saved contact segment by its uuid, including its embedded stage, tag uuids, recency threshold, and live contacts_count.
# Update a saved contact segment.
Source: https://docs.dmly.io/api-reference/segments/update-a-saved-contact-segment
/api-reference/openapi.json put /segments/{segment}
Updates a segment by uuid. All fields are optional; only supplied fields are changed. Same validation rules as create (name max 80, stage uuid, tag uuid arrays, min_days_inactive >= 0, and the same conditions rule tree with its fields, operators and caps of 20 groups by 30 rules). Sending conditions REPLACES the stored tree; it is not merged. The same sanitising applies, so an unrecognised field or operator is dropped silently and the request still succeeds, and a tree in which nothing survives clears the segment's conditions. conditions is still combined with the flat stage/tag/recency fields using AND, and is not returned in the response. Accepts PUT or PATCH.
# Create a catalogue service.
Source: https://docs.dmly.io/api-reference/services-&-service-categories/create-a-catalogue-service
/api-reference/openapi.json post /services
Creates an appointment, class, or add-on service. category (a ServiceCategory uuid) is resolved server-side. payment_mode sets payment_required automatically. For add_on, booking-policy fields are forced to non-bookable defaults; capacity only applies to class. A unique slug is generated from the name. Dispatches ServiceCreated (and ServiceActivated when active). Returns 201.
# Create a service category.
Source: https://docs.dmly.io/api-reference/services-&-service-categories/create-a-service-category
/api-reference/openapi.json post /service-categories
Creates a catalogue category. A unique slug is generated from the name and sort_order is auto-assigned (max+1). Returns 201.
# Delete a category.
Source: https://docs.dmly.io/api-reference/services-&-service-categories/delete-a-category
/api-reference/openapi.json delete /service-categories/{category}
Deletes a category resolved by uuid. Any linked services, products and plans are first detached (category_id set to null) so they fall back to 'no category' rather than orphaning. Returns deleted=true.
# Delete (or deactivate) a service.
Source: https://docs.dmly.io/api-reference/services-&-service-categories/delete-or-deactivate-a-service
/api-reference/openapi.json delete /services/{service}
Deletes a service resolved by uuid. If the service has bookings it is deactivated (active=false) instead of deleted to preserve history; the response then has deleted=false with an explanatory message. Otherwise returns deleted=true.
# List catalogue services (paginated).
Source: https://docs.dmly.io/api-reference/services-&-service-categories/list-catalogue-services-paginated
/api-reference/openapi.json get /services
Returns a paginated list of services for the current workspace, ordered by sort_order then name. Each service embeds its category (id=uuid, name, kind) when present. Supports filtering by type, category, and a name search.
# List service categories (paginated).
Source: https://docs.dmly.io/api-reference/services-&-service-categories/list-service-categories-paginated
/api-reference/openapi.json get /service-categories
Returns a paginated list of catalogue categories for the current workspace, ordered by sort_order then name, each with a services_count. Supports filtering by kind and a name search.
# Retrieve a single category.
Source: https://docs.dmly.io/api-reference/services-&-service-categories/retrieve-a-single-category
/api-reference/openapi.json get /service-categories/{category}
Returns one category (resolved by its uuid) with its services_count. 404 if not found in the current workspace.
# Retrieve a single service.
Source: https://docs.dmly.io/api-reference/services-&-service-categories/retrieve-a-single-service
/api-reference/openapi.json get /services/{service}
Returns one service (resolved by its uuid) with its embedded category. 404 if not found in the current workspace.
# Update a category (full or partial).
Source: https://docs.dmly.io/api-reference/services-&-service-categories/update-a-category-full-or-partial
/api-reference/openapi.json put /service-categories/{category}
Updates a category resolved by uuid. Accepts PUT or PATCH; all fields optional. Unspecified fields are left untouched.
# Update a service (full or partial).
Source: https://docs.dmly.io/api-reference/services-&-service-categories/update-a-service-full-or-partial
/api-reference/openapi.json put /services/{service}
Updates a service resolved by uuid. Accepts PUT or PATCH; all fields are optional and unspecified fields are left untouched. type changes re-shape add_on/class-specific fields. payment_mode resets payment_required. slug is preserved. Dispatches ServiceUpdated (plus ServiceActivated/Deactivated when active flips).
# Add time off for a staff member.
Source: https://docs.dmly.io/api-reference/staff/add-time-off-for-a-staff-member
/api-reference/openapi.json post /staff/{staff}/time-off
Creates an availability override of type time_off for the staff member, subtracting hours over the given UTC window. starts_at and ends_at are required (ends_at must be >= starts_at). Returns 201 with the created override.
# Create a staff member.
Source: https://docs.dmly.io/api-reference/staff/create-a-staff-member
/api-reference/openapi.json post /staff
Creates a bookable staff member. Only name is required; accepts_bookings and active default to true. Returns 201 with the created staff.
# Delete a staff member.
Source: https://docs.dmly.io/api-reference/staff/delete-a-staff-member
/api-reference/openapi.json delete /staff/{staff}
Deletes the staff member identified by its uuid. Returns {"deleted": true}.
# Delete a staff member's time off.
Source: https://docs.dmly.io/api-reference/staff/delete-a-staff-members-time-off
/api-reference/openapi.json delete /staff/{staff}/time-off/{override}
Deletes the availability override (identified by its uuid) belonging to the given staff member. Returns {"deleted": true}.
# Get a staff member's availability.
Source: https://docs.dmly.io/api-reference/staff/get-a-staff-members-availability
/api-reference/openapi.json get /staff/{staff}/availability
Returns the staff member's weekly working-hours rules (day_of_week 0=Sun..6=Sat with HH:MM start/end in the staff timezone) plus any one-off overrides (block / time_off / extra). Optional from/to dates constrain which overrides are returned; the weekly rules are always returned in full.
# List bookable staff members.
Source: https://docs.dmly.io/api-reference/staff/list-bookable-staff-members
/api-reference/openapi.json get /staff
Returns a paginated list of staff members, ordered by sort_order then name. Supports search and active/accepts_bookings filters.
# Retrieve a single staff member.
Source: https://docs.dmly.io/api-reference/staff/retrieve-a-single-staff-member
/api-reference/openapi.json get /staff/{staff}
Returns the staff member identified by its uuid.
# Update a staff member.
Source: https://docs.dmly.io/api-reference/staff/update-a-staff-member
/api-reference/openapi.json put /staff/{staff}
Updates the staff member identified by its uuid. All fields are optional; only supplied fields are changed. Also accepts PATCH.
# Create a tag
Source: https://docs.dmly.io/api-reference/tags/create-a-tag
/api-reference/openapi.json post /tags
Creates a tag (or returns the existing one with the same name).
# Delete a tag
Source: https://docs.dmly.io/api-reference/tags/delete-a-tag
/api-reference/openapi.json delete /tags/{tag}
Deletes a tag and detaches it from contacts. The built-in 'Unsubscribe' tag cannot be deleted.
# Get a tag
Source: https://docs.dmly.io/api-reference/tags/get-a-tag
/api-reference/openapi.json get /tags/{tag}
Returns a single tag by id.
# List tags
Source: https://docs.dmly.io/api-reference/tags/list-tags
/api-reference/openapi.json get /tags
Paginated list of contact tags with contact counts.
# Update a tag
Source: https://docs.dmly.io/api-reference/tags/update-a-tag
/api-reference/openapi.json put /tags/{tag}
Renames or recolours a tag.
# Webhooks
Source: https://docs.dmly.io/api-reference/webhooks
Subscribe to 83 events and DMLY posts to your endpoint as they happen.
Rather than polling the API, register an endpoint and DMLY will `POST` to it whenever
something happens: a message arrives, an invoice is paid, an appointment is booked.
## Setting one up
Go to **Configurations → Integrations → Webhooks** and add your endpoint URL. It must be
publicly reachable over HTTPS.
Subscribe to the topics you care about, then select **Connect**. Subscribe narrowly:
every event you accept and discard is a request your server paid for.
DMLY generates a signing secret for you; you don't supply one. Once connected, the
**Signing secret** panel appears: select **Reveal**, then **Copy**. Every delivery to
your own endpoint is signed, so verify it. See [Verifying](#verifying-a-delivery).
Use the test ping to confirm your endpoint is reachable and your signature check works
before relying on it.
## The payload
Every delivery has the same envelope. Only `data` varies by topic:
```json theme={"dark"}
{
"id": "9f1c4b6e-3a2d-4f8b-9c1e-2a7d5e8b0c31",
"event": "contact.created",
"occurred_at": "2026-07-14T09:30:00+00:00",
"workspace_id": 42,
"data": {
"contact": { "id": "…", "name": "Ada Lovelace", "phone": "+15551230001" }
}
}
```
Test pings carry an extra `"test": true` so you can ignore them in production logic.
## Headers
| Header | Value |
| - | - |
| `X-Dmly-Event` | The topic, e.g. `contact.created` |
| `X-Dmly-Delivery` | Id for this delivery. **Stable across retries**: for your own endpoints it is the envelope `id` |
| `X-Dmly-Signature` | `sha256=`, sent on every delivery to your own endpoints |
| `User-Agent` | `Salesbot-Webhooks/1.0` |
These headers used to be called `X-Salesbot-Event`, `X-Salesbot-Delivery` and
`X-Salesbot-Signature`. Every delivery still carries the old names as well, with exactly
the same values, so an endpoint that verifies `X-Salesbot-Signature` keeps working. The old
names are deprecated and will be removed in a future release: read the `X-Dmly-` headers in
new code, and switch existing verifiers over.
## Verifying a delivery
The signature is an HMAC-SHA256 of the **raw request body**, keyed by your secret. Compute
it over the bytes exactly as received; parsing and re-serialising the JSON first will
change the bytes and the signature will never match.
```python theme={"dark"}
import hmac, hashlib
def verify(raw_body: bytes, header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, header)
```
Always compare with a constant-time function (`hmac.compare_digest`, `crypto.timingSafeEqual`).
A plain `==` leaks timing information that can be used to forge a signature.
Zapier hook URLs are not signed. Their URLs are unguessable capability URLs, so they carry
no `X-Dmly-Signature` header. Signing applies to your own webhook endpoints.
## Responding
Return any `2xx` to acknowledge. DMLY allows **8 seconds** before it treats the delivery as
failed, so acknowledge immediately and do the real work asynchronously; do not process the
event inline and then respond.
Failed deliveries are retried twice more, after roughly **10s** then **60s** (three
attempts in total). After that the delivery is logged as failed and dropped.
Deliveries are at-least-once, not exactly-once: a timeout on your side can produce a
retry for an event you already handled. Deduplicate on the envelope's `id` (or the
identical `X-Dmly-Delivery` header), which is stable across retries of the same
event.
## Events
There are 83 topics across contacts, conversations, calls, message lifecycle,
appointments, classes, invoices, orders, payments, subscriptions, credits, the store,
automations and broadcasts.
Most are documented with their full payload in the sidebar under this section. The sidebar
does not yet cover eight store topics: `store.order_in_transit`, `store.order_out_for_delivery`,
`store.order_delivered`, `store.order_ready_for_pickup`, `store.order_delivery_failed`,
`store.customer_created`, `store.customer_opted_in` and `store.customer_opted_out`. For the
complete, current list, call `GET /api/v1/webhooks/events`, which always returns every topic.
# Appointment cancelled
Source: https://docs.dmly.io/api-reference/webhooks-appointments/appointment-cancelled
/api-reference/openapi.json webhook booking.cancelled
Sent to your endpoint when **Appointment cancelled** occurs (`booking.cancelled`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Appointment completed
Source: https://docs.dmly.io/api-reference/webhooks-appointments/appointment-completed
/api-reference/openapi.json webhook appointment.completed
Sent to your endpoint when **Appointment completed** occurs (`appointment.completed`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Appointment confirmed
Source: https://docs.dmly.io/api-reference/webhooks-appointments/appointment-confirmed
/api-reference/openapi.json webhook appointment.confirmed
Sent to your endpoint when **Appointment confirmed** occurs (`appointment.confirmed`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Appointment no show
Source: https://docs.dmly.io/api-reference/webhooks-appointments/appointment-no-show
/api-reference/openapi.json webhook appointment.no_show
Sent to your endpoint when **Appointment no-show** occurs (`appointment.no_show`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Appointment reminder due
Source: https://docs.dmly.io/api-reference/webhooks-appointments/appointment-reminder-due
/api-reference/openapi.json webhook appointment.reminder_due
Sent to your endpoint when **Appointment reminder due** occurs (`appointment.reminder_due`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Appointment rescheduled
Source: https://docs.dmly.io/api-reference/webhooks-appointments/appointment-rescheduled
/api-reference/openapi.json webhook booking.rescheduled
Sent to your endpoint when **Appointment rescheduled** occurs (`booking.rescheduled`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Appointment scheduled
Source: https://docs.dmly.io/api-reference/webhooks-appointments/appointment-scheduled
/api-reference/openapi.json webhook booking.created
Sent to your endpoint when **Appointment scheduled** occurs (`booking.created`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Appointment starts soon
Source: https://docs.dmly.io/api-reference/webhooks-appointments/appointment-starts-soon
/api-reference/openapi.json webhook appointment.starts_soon
Sent to your endpoint when **Appointment starts soon** occurs (`appointment.starts_soon`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Automation completed
Source: https://docs.dmly.io/api-reference/webhooks-automations/automation-completed
/api-reference/openapi.json webhook automation.completed
Sent to your endpoint when **Automation completed** occurs (`automation.completed`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Automation failed
Source: https://docs.dmly.io/api-reference/webhooks-automations/automation-failed
/api-reference/openapi.json webhook automation.failed
Sent to your endpoint when **Automation failed** occurs (`automation.failed`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Automation triggered
Source: https://docs.dmly.io/api-reference/webhooks-automations/automation-triggered
/api-reference/openapi.json webhook automation.triggered
Sent to your endpoint when **Automation triggered** occurs (`automation.triggered`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Broadcast completed
Source: https://docs.dmly.io/api-reference/webhooks-broadcasts/broadcast-completed
/api-reference/openapi.json webhook broadcast.completed
Sent to your endpoint when **Broadcast completed** occurs (`broadcast.completed`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Broadcast failed
Source: https://docs.dmly.io/api-reference/webhooks-broadcasts/broadcast-failed
/api-reference/openapi.json webhook broadcast.failed
Sent to your endpoint when **Broadcast failed** occurs (`broadcast.failed`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Broadcast sent
Source: https://docs.dmly.io/api-reference/webhooks-broadcasts/broadcast-sent
/api-reference/openapi.json webhook broadcast.sent
Sent to your endpoint when **Broadcast sent** occurs (`broadcast.sent`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Call completed
Source: https://docs.dmly.io/api-reference/webhooks-calls/call-completed
/api-reference/openapi.json webhook call.completed
Sent to your endpoint when **Call completed** occurs (`call.completed`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Call declined
Source: https://docs.dmly.io/api-reference/webhooks-calls/call-declined
/api-reference/openapi.json webhook call.rejected
Sent to your endpoint when **Call declined** occurs (`call.rejected`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Incoming call
Source: https://docs.dmly.io/api-reference/webhooks-calls/incoming-call
/api-reference/openapi.json webhook call.incoming
Sent to your endpoint when **Incoming call** occurs (`call.incoming`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Missed call
Source: https://docs.dmly.io/api-reference/webhooks-calls/missed-call
/api-reference/openapi.json webhook call.missed
Sent to your endpoint when **Missed call** occurs (`call.missed`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Class booked
Source: https://docs.dmly.io/api-reference/webhooks-classes/class-booked
/api-reference/openapi.json webhook class.booked
Sent to your endpoint when **Class booked** occurs (`class.booked`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Class cancelled
Source: https://docs.dmly.io/api-reference/webhooks-classes/class-cancelled
/api-reference/openapi.json webhook class.cancelled
Sent to your endpoint when **Class cancelled** occurs (`class.cancelled`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Class full
Source: https://docs.dmly.io/api-reference/webhooks-classes/class-full
/api-reference/openapi.json webhook class.full
Sent to your endpoint when **Class full** occurs (`class.full`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Class seat opened
Source: https://docs.dmly.io/api-reference/webhooks-classes/class-seat-opened
/api-reference/openapi.json webhook class.seat_opened
Sent to your endpoint when **Class seat opened** occurs (`class.seat_opened`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Class starts soon
Source: https://docs.dmly.io/api-reference/webhooks-classes/class-starts-soon
/api-reference/openapi.json webhook class.starts_soon
Sent to your endpoint when **Class starts soon** occurs (`class.starts_soon`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Joined class waitlist
Source: https://docs.dmly.io/api-reference/webhooks-classes/joined-class-waitlist
/api-reference/openapi.json webhook class.waitlisted
Sent to your endpoint when **Joined class waitlist** occurs (`class.waitlisted`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Became a client
Source: https://docs.dmly.io/api-reference/webhooks-contacts-and-clients/became-a-client
/api-reference/openapi.json webhook contact.converted_to_client
Sent to your endpoint when **Became a client** occurs (`contact.converted_to_client`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Birthday key date
Source: https://docs.dmly.io/api-reference/webhooks-contacts-and-clients/birthday-key-date
/api-reference/openapi.json webhook contact.date_reached
Sent to your endpoint when **Birthday / key date** occurs (`contact.date_reached`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Contact updated
Source: https://docs.dmly.io/api-reference/webhooks-contacts-and-clients/contact-updated
/api-reference/openapi.json webhook contact.updated
Sent to your endpoint when **Contact updated** occurs (`contact.updated`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Lifecycle changed
Source: https://docs.dmly.io/api-reference/webhooks-contacts-and-clients/lifecycle-changed
/api-reference/openapi.json webhook contact.lifecycle_changed
Sent to your endpoint when **Lifecycle changed** occurs (`contact.lifecycle_changed`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# New contact
Source: https://docs.dmly.io/api-reference/webhooks-contacts-and-clients/new-contact
/api-reference/openapi.json webhook contact.created
Sent to your endpoint when **New contact** occurs (`contact.created`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Pipeline stage changed
Source: https://docs.dmly.io/api-reference/webhooks-contacts-and-clients/pipeline-stage-changed
/api-reference/openapi.json webhook contact.stage_changed
Sent to your endpoint when **Pipeline stage changed** occurs (`contact.stage_changed`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Tag added
Source: https://docs.dmly.io/api-reference/webhooks-contacts-and-clients/tag-added
/api-reference/openapi.json webhook contact.tagged
Sent to your endpoint when **Tag added** occurs (`contact.tagged`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Tag removed
Source: https://docs.dmly.io/api-reference/webhooks-contacts-and-clients/tag-removed
/api-reference/openapi.json webhook contact.untagged
Sent to your endpoint when **Tag removed** occurs (`contact.untagged`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Conversation assigned
Source: https://docs.dmly.io/api-reference/webhooks-conversations/conversation-assigned
/api-reference/openapi.json webhook conversation.assigned
Sent to your endpoint when **Conversation assigned** occurs (`conversation.assigned`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Conversation closed
Source: https://docs.dmly.io/api-reference/webhooks-conversations/conversation-closed
/api-reference/openapi.json webhook conversation.closed
Sent to your endpoint when **Conversation closed** occurs (`conversation.closed`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Inbound message received
Source: https://docs.dmly.io/api-reference/webhooks-conversations/inbound-message-received
/api-reference/openapi.json webhook message.received
Sent to your endpoint when **Inbound message received** occurs (`message.received`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Invoice created draft
Source: https://docs.dmly.io/api-reference/webhooks-invoices/invoice-created-draft
/api-reference/openapi.json webhook invoice.created
Sent to your endpoint when **Invoice created (draft)** occurs (`invoice.created`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Invoice fully paid
Source: https://docs.dmly.io/api-reference/webhooks-invoices/invoice-fully-paid
/api-reference/openapi.json webhook invoice.paid
Sent to your endpoint when **Invoice fully paid** occurs (`invoice.paid`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Invoice overdue
Source: https://docs.dmly.io/api-reference/webhooks-invoices/invoice-overdue
/api-reference/openapi.json webhook invoice.overdue
Sent to your endpoint when **Invoice overdue** occurs (`invoice.overdue`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Invoice partially paid
Source: https://docs.dmly.io/api-reference/webhooks-invoices/invoice-partially-paid
/api-reference/openapi.json webhook invoice.partially_paid
Sent to your endpoint when **Invoice partially paid** occurs (`invoice.partially_paid`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Invoice sent
Source: https://docs.dmly.io/api-reference/webhooks-invoices/invoice-sent
/api-reference/openapi.json webhook invoice.sent
Sent to your endpoint when **Invoice sent** occurs (`invoice.sent`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Invoice voided
Source: https://docs.dmly.io/api-reference/webhooks-invoices/invoice-voided
/api-reference/openapi.json webhook invoice.voided
Sent to your endpoint when **Invoice voided** occurs (`invoice.voided`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Message delivered
Source: https://docs.dmly.io/api-reference/webhooks-message-lifecycle/message-delivered
/api-reference/openapi.json webhook message.delivered
Sent to your endpoint when **Message delivered** occurs (`message.delivered`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Message failed
Source: https://docs.dmly.io/api-reference/webhooks-message-lifecycle/message-failed
/api-reference/openapi.json webhook message.failed
Sent to your endpoint when **Message failed** occurs (`message.failed`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Message read seen
Source: https://docs.dmly.io/api-reference/webhooks-message-lifecycle/message-read-seen
/api-reference/openapi.json webhook message.read_or_seen
Sent to your endpoint when **Message read / seen** occurs (`message.read_or_seen`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Message sent
Source: https://docs.dmly.io/api-reference/webhooks-message-lifecycle/message-sent
/api-reference/openapi.json webhook message.sent
Sent to your endpoint when **Message sent** occurs (`message.sent`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Coupon redeemed
Source: https://docs.dmly.io/api-reference/webhooks-offerings-and-credits/coupon-redeemed
/api-reference/openapi.json webhook coupon.redeemed
Sent to your endpoint when **Coupon redeemed** occurs (`coupon.redeemed`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Credits issued
Source: https://docs.dmly.io/api-reference/webhooks-offerings-and-credits/credits-issued
/api-reference/openapi.json webhook credits.issued
Sent to your endpoint when **Credits issued** occurs (`credits.issued`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Credits low
Source: https://docs.dmly.io/api-reference/webhooks-offerings-and-credits/credits-low
/api-reference/openapi.json webhook credits.low
Sent to your endpoint when **Credits low** occurs (`credits.low`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Credits used
Source: https://docs.dmly.io/api-reference/webhooks-offerings-and-credits/credits-used
/api-reference/openapi.json webhook credits.used
Sent to your endpoint when **Credits used** occurs (`credits.used`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Loyalty points activity
Source: https://docs.dmly.io/api-reference/webhooks-offerings-and-credits/loyalty-points-activity
/api-reference/openapi.json webhook loyalty.transaction
Sent to your endpoint when **Loyalty points activity** occurs (`loyalty.transaction`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Product back in stock
Source: https://docs.dmly.io/api-reference/webhooks-offerings-and-credits/product-back-in-stock
/api-reference/openapi.json webhook offering.back_in_stock
Sent to your endpoint when **Product back in stock** occurs (`offering.back_in_stock`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Product stock low
Source: https://docs.dmly.io/api-reference/webhooks-offerings-and-credits/product-stock-low
/api-reference/openapi.json webhook offering.stock_low
Sent to your endpoint when **Product stock low** occurs (`offering.stock_low`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Order cancelled
Source: https://docs.dmly.io/api-reference/webhooks-orders/order-cancelled
/api-reference/openapi.json webhook order.cancelled
Sent to your endpoint when **Order cancelled** occurs (`order.cancelled`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Order confirmed
Source: https://docs.dmly.io/api-reference/webhooks-orders/order-confirmed
/api-reference/openapi.json webhook order.confirmed
Sent to your endpoint when **Order confirmed** occurs (`order.confirmed`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Order created
Source: https://docs.dmly.io/api-reference/webhooks-orders/order-created
/api-reference/openapi.json webhook order.created
Sent to your endpoint when **Order created** occurs (`order.created`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Order paid
Source: https://docs.dmly.io/api-reference/webhooks-orders/order-paid
/api-reference/openapi.json webhook order.paid
Sent to your endpoint when **Order paid** occurs (`order.paid`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Payment failed
Source: https://docs.dmly.io/api-reference/webhooks-payments/payment-failed
/api-reference/openapi.json webhook payment.failed
Sent to your endpoint when **Payment failed** occurs (`payment.failed`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Payment refunded
Source: https://docs.dmly.io/api-reference/webhooks-payments/payment-refunded
/api-reference/openapi.json webhook payment.refunded
Sent to your endpoint when **Payment refunded** occurs (`payment.refunded`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Payment succeeded
Source: https://docs.dmly.io/api-reference/webhooks-payments/payment-succeeded
/api-reference/openapi.json webhook payment.succeeded
Sent to your endpoint when **Payment succeeded** occurs (`payment.succeeded`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Checkout abandoned
Source: https://docs.dmly.io/api-reference/webhooks-store-e-commerce/checkout-abandoned
/api-reference/openapi.json webhook store.checkout_abandoned
Sent to your endpoint when **Checkout abandoned** occurs (`store.checkout_abandoned`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Customer milestone
Source: https://docs.dmly.io/api-reference/webhooks-store-e-commerce/customer-milestone
/api-reference/openapi.json webhook store.customer_milestone
Sent to your endpoint when **Customer milestone** occurs (`store.customer_milestone`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Inventory low
Source: https://docs.dmly.io/api-reference/webhooks-store-e-commerce/inventory-low
/api-reference/openapi.json webhook store.inventory_low
Sent to your endpoint when **Inventory low** occurs (`store.inventory_low`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Product back in stock store
Source: https://docs.dmly.io/api-reference/webhooks-store-e-commerce/product-back-in-stock-store
/api-reference/openapi.json webhook store.product_back_in_stock
Sent to your endpoint when **Product back in stock (store)** occurs (`store.product_back_in_stock`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Store order cancelled
Source: https://docs.dmly.io/api-reference/webhooks-store-e-commerce/store-order-cancelled
/api-reference/openapi.json webhook store.order_cancelled
Sent to your endpoint when **Store order cancelled** occurs (`store.order_cancelled`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Store order paid
Source: https://docs.dmly.io/api-reference/webhooks-store-e-commerce/store-order-paid
/api-reference/openapi.json webhook store.order_paid
Sent to your endpoint when **Store order paid** occurs (`store.order_paid`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Store order placed
Source: https://docs.dmly.io/api-reference/webhooks-store-e-commerce/store-order-placed
/api-reference/openapi.json webhook store.order_created
Sent to your endpoint when **Store order placed** occurs (`store.order_created`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Store order refunded
Source: https://docs.dmly.io/api-reference/webhooks-store-e-commerce/store-order-refunded
/api-reference/openapi.json webhook store.order_refunded
Sent to your endpoint when **Store order refunded** occurs (`store.order_refunded`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Store order shipped
Source: https://docs.dmly.io/api-reference/webhooks-store-e-commerce/store-order-shipped
/api-reference/openapi.json webhook store.order_fulfilled
Sent to your endpoint when **Store order shipped** occurs (`store.order_fulfilled`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Store payment failed
Source: https://docs.dmly.io/api-reference/webhooks-store-e-commerce/store-payment-failed
/api-reference/openapi.json webhook store.payment_failed
Sent to your endpoint when **Store payment failed** occurs (`store.payment_failed`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Subscription cancelled
Source: https://docs.dmly.io/api-reference/webhooks-subscriptions/subscription-cancelled
/api-reference/openapi.json webhook subscription.cancelled
Sent to your endpoint when **Subscription cancelled** occurs (`subscription.cancelled`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Subscription created
Source: https://docs.dmly.io/api-reference/webhooks-subscriptions/subscription-created
/api-reference/openapi.json webhook subscription.created
Sent to your endpoint when **Subscription created** occurs (`subscription.created`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Subscription paused
Source: https://docs.dmly.io/api-reference/webhooks-subscriptions/subscription-paused
/api-reference/openapi.json webhook subscription.paused
Sent to your endpoint when **Subscription paused** occurs (`subscription.paused`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Subscription payment failed
Source: https://docs.dmly.io/api-reference/webhooks-subscriptions/subscription-payment-failed
/api-reference/openapi.json webhook subscription.payment_failed
Sent to your endpoint when **Subscription payment failed** occurs (`subscription.payment_failed`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Subscription plan changed
Source: https://docs.dmly.io/api-reference/webhooks-subscriptions/subscription-plan-changed
/api-reference/openapi.json webhook subscription.plan_changed
Sent to your endpoint when **Subscription plan changed** occurs (`subscription.plan_changed`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Subscription renewed
Source: https://docs.dmly.io/api-reference/webhooks-subscriptions/subscription-renewed
/api-reference/openapi.json webhook subscription.renewed
Sent to your endpoint when **Subscription renewed** occurs (`subscription.renewed`).
Signed as `X-Dmly-Signature: sha256=` when the endpoint has a secret. Return any 2xx within 8 seconds to acknowledge.
# Disconnect the workspace's first webhook endpoint.
Source: https://docs.dmly.io/api-reference/webhooks/disconnect-the-workspaces-first-webhook-endpoint
/api-reference/openapi.json delete /webhooks
Disconnects the workspace's FIRST outbound webhook endpoint: clears its endpoint, subscribed events, channel scope and signing secret, and sets its status to disconnected. Any other webhook endpoint in the workspace keeps delivering. No further events go to this one until a new subscription is saved via PUT /webhooks. Returns {"deleted": true}.
# Get the workspace's first webhook endpoint.
Source: https://docs.dmly.io/api-reference/webhooks/get-the-workspaces-first-webhook-endpoint
/api-reference/openapi.json get /webhooks
Returns one outbound webhook endpoint: the destination endpoint URL, the list of subscribed event topics, the channel scope, the connection status (connected/disconnected/error), and whether a signing secret is set (has_secret). The signing secret value itself is never returned. A workspace can have SEVERAL webhook endpoints, added in the dashboard under Configurations → Integrations → Webhooks with "Create webhook", and DMLY delivers every event to every connected one. These API routes are unparameterised and address only the FIRST webhook endpoint in the workspace: there is no way here to list, create, delete or select a specific endpoint, so this response is not a complete picture of where your events are going. If no webhook has been configured yet the endpoint is null, events is an empty array, channel defaults to "all" and status is "disconnected".
# List the full subscribable event catalogue, grouped.
Source: https://docs.dmly.io/api-reference/webhooks/list-the-full-subscribable-event-catalogue-grouped
/api-reference/openapi.json get /webhooks/events
Returns the complete catalogue of webhook event topics you can subscribe to, organised into ordered display groups (Contacts & clients, Conversations, Message lifecycle, Appointments, Classes, Invoices, Orders, Payments, Subscriptions, Offerings & credits, Store, Automations, Broadcasts). Each group is {group: string, topics: {: