Submit a booking
curl --request POST \
--url https://deepcontext.app/api/public/tenants/{slug}/bookings \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: <idempotency-key>' \
--data '
{
"service_id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"starts_at": "2023-11-07T05:31:56Z",
"customer_name": "<string>",
"customer_phone": "<string>",
"turnstile_token": "<string>"
}
'const options = {
method: 'POST',
headers: {'Idempotency-Key': '<idempotency-key>', 'Content-Type': 'application/json'},
body: JSON.stringify({
service_id: '3c90c3cc-0d44-4b50-8888-8dd25736052a',
starts_at: '2023-11-07T05:31:56Z',
customer_name: '<string>',
customer_phone: '<string>',
turnstile_token: '<string>'
})
};
fetch('https://deepcontext.app/api/public/tenants/{slug}/bookings', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));{
"acknowledgment": "<string>"
}{
"booking": {
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"status": "pending",
"service_id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"starts_at": "<string>",
"ends_at": "<string>"
},
"acknowledgment": "<string>"
}{
"error": {
"code": "<string>",
"message": "<string>"
}
}{
"error": {
"code": "<string>",
"message": "<string>"
}
}{
"error": {
"code": "<string>",
"message": "<string>"
}
}{
"error": {
"code": "<string>",
"message": "<string>"
}
}{
"error": {
"code": "<string>",
"message": "<string>"
}
}{
"error": {
"code": "<string>",
"message": "<string>"
}
}{
"error": {
"code": "<string>",
"message": "<string>"
}
}API reference
Submit a booking
The single write. Requires an idempotency key and a Turnstile token.
POST
/
api
/
public
/
tenants
/
{slug}
/
bookings
Submit a booking
curl --request POST \
--url https://deepcontext.app/api/public/tenants/{slug}/bookings \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: <idempotency-key>' \
--data '
{
"service_id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"starts_at": "2023-11-07T05:31:56Z",
"customer_name": "<string>",
"customer_phone": "<string>",
"turnstile_token": "<string>"
}
'const options = {
method: 'POST',
headers: {'Idempotency-Key': '<idempotency-key>', 'Content-Type': 'application/json'},
body: JSON.stringify({
service_id: '3c90c3cc-0d44-4b50-8888-8dd25736052a',
starts_at: '2023-11-07T05:31:56Z',
customer_name: '<string>',
customer_phone: '<string>',
turnstile_token: '<string>'
})
};
fetch('https://deepcontext.app/api/public/tenants/{slug}/bookings', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));{
"acknowledgment": "<string>"
}{
"booking": {
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"status": "pending",
"service_id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"starts_at": "<string>",
"ends_at": "<string>"
},
"acknowledgment": "<string>"
}{
"error": {
"code": "<string>",
"message": "<string>"
}
}{
"error": {
"code": "<string>",
"message": "<string>"
}
}{
"error": {
"code": "<string>",
"message": "<string>"
}
}{
"error": {
"code": "<string>",
"message": "<string>"
}
}{
"error": {
"code": "<string>",
"message": "<string>"
}
}{
"error": {
"code": "<string>",
"message": "<string>"
}
}{
"error": {
"code": "<string>",
"message": "<string>"
}
}The only write in this API. It creates the booking, matches or creates the contact, and
fires the operator notification and the customer’s confirmations off the response path.
Who the customer is
customer_name and customer_phone are required. customer_email is optional — send
null, or omit it, for a customer who doesn’t give one. An empty string is accepted and
treated as “not provided”; anything else must be a valid address, because an optional
field is still not an unvalidated one.
This changed. Email used to be the required identifier and phone the optional one.
A payload that sends an email and no phone is now
400. Both fields are still stored
when you send both, and both still take part in matching an existing contact — email
first, then phone.Confirmations
Every booking notifies the operator. What the customer receives depends on what they gave you, and the two channels are independent: Failure to send on either channel is not treated as an error. The booking is created and acknowledged identically whether both, one, or neither goes out. There is no field in the response that tells you which fired, and no error code for a skip. Every one of these must hold for the email:customer_emailwas supplied and passed validation- the address is on neither our suppression list nor the SES account-level one
- the tenant has a usable sending identity — their own provisioned domain, or the system fallback if that domain is unhealthy
sms_consentwastrueon this booking- a phone number is on the booking, and it resolved to a contact record
- that contact still holds transactional SMS consent, and has not sent
STOPor been otherwise suppressed - the tenant has a phone line that is both active and A2P/10DLC registered
- the rendered message fits inside the carrier’s segment ceiling
- the booking has not since moved to a terminal status (
cancelled,no_show,completed) — reachable when you retry anIdempotency-Keysome time later
201 never means “a text was sent.”
sms_consent must be an explicit opt-in, unticked by default, that the customer sets
themselves — it is a TCPA consent record, and asserting it on their behalf is not
something you may do. Sending true when the tenant has no registered SMS line is
harmless: the consent is recorded and no text is sent. Check sms_consent_enabled on the
config endpoint before rendering the checkbox at all.
The playground is read-only for this endpoint. It can’t mint the single-use Turnstile
token the route requires, and firing it would write a real booking to a real tenant and
burn that contact’s daily quota. Copy the request and run it against your own page.
Idempotency
Idempotency-Key is required: any caller-generated value, 1–128 printable characters.
A retry with the same key returns the original booking instead of creating a second one,
and the check runs before Turnstile — deliberately, because Turnstile tokens are
single-use and a network retry would otherwise fail on a spent token.
Generate a fresh key whenever the customer picks a different slot. That’s a different
logical booking, and reusing the key would replay the old one.
A replay returns the stored booking, whatever has happened to it since. The lookup
is by tenant and key only. If the operator cancelled the booking in the dashboard and
the client then retries that key,
booking.status comes back cancelled — which is
why status spans the full set rather than just pending | confirmed. Read the status
from the response rather than assuming the value you got the first time still holds.The honeypot will swallow you silently
website is a honeypot. A non-empty value returns 200 with a believable
acknowledgment and writes nothing at all. Bind it to a hidden, autocomplete="off"
input and never populate it — including from a password manager or an over-eager
autofill.
Branch on status === 201, not on the presence of acknowledgment. The honeypot reply
has no booking key precisely so the two are distinguishable.
Turnstile
You cannot use your own Turnstile key. Every booking verifies against one server-side secret, so a token minted by a different site key is rejected asturnstile_failed.
Render the widget with the site key DPC issues you, and ask us to add your domain to that
widget’s allowlist — otherwise the widget won’t render on your domain at all.
Reset the widget after any rejected submit. The token is spent either way.
Answers
form_data is validated against the questions attached to the submitted service_id.
A key this service doesn’t ask is 400 invalid_form_data — that’s what stops a stale
payload, from a customer who backed up and switched service, recording answers to
questions the appointment never asked.
What gets stored is the normalized answer set, not your raw body: numeric strings become
numbers, strings are trimmed, and an unchecked box is a real false. The
client-side normalizer in the guide mirrors those rules exactly.Headers
Caller-generated, 1–128 printable characters. Reuse it when retrying the same booking; generate a new one when the customer picks a different slot.
Required string length:
1 - 128Path Parameters
The tenant's public booking slug. Issued by DPC; there is no discovery endpoint.
Body
application/json
Pattern:
^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$Pattern:
^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$Required string length:
1 - 200Required string length:
1 - 50Required string length:
1 - 2048Show child attributes
Show child attributes
Response
Honeypot triggered — website was non-empty. Deliberately indistinguishable prose, but NO booking was created and there is no booking key. Branch on the 201 status, not on the presence of acknowledgment.