Writes: contacts, visits, promo codes and drafts
Feed the customer base from a till or a CRM, record visits, redeem promo codes and drop reply drafts.
API documentationGuides · Writes
Before writing
Writes are in beta. They follow the same rules as the actions taken in the app, and each one requires a dedicated scope:
| Scope | Operations |
|---|---|
contacts:write | POST /v1/contacts, PATCH /v1/contacts/{contactId} |
visits:write | POST /v1/visits |
promo_codes:redeem | POST /v1/promo-codes/redeem |
replies:write | PUT /v1/reviews/{reviewId}/reply-draft |
ai:generate | POST /v1/reviews/{reviewId}/reply-drafts |
The Till template, offered when creating a key, gathers establishments:read, contacts:write, visits:write and promo_codes:redeem. Publishing a reply on Google has its own guide: publishing on Google.
- Every write is refused with
403 tenant_read_onlyon a read-only account, for instance after the trial ends. - Every write accepts the
Idempotency-Keyheader, so it can be sent again without risking a duplicate: see the idempotency guide. - Bodies are sent as JSON, with
Content-Type: application/json. An unknown field answers400 invalid_request. - Writes produce the same events as the app, with the
apiorigin: a contact created through the API gives acontact.createdevent with theapiorigin.
Contacts and consent
POST /v1/contacts adds a customer to a location's base, or completes the contact that already has the same email or phone within that location:
curl https://api.mabellenote.com/v1/contacts \
-H "Authorization: Bearer $MBN_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: crm-contact-58213" \
-d '{
"establishmentId": "kh2n8p5r3t6v9b1c4d7f0g2j5k8m1n3p",
"email": "[email protected]",
"phone": "+33612345678",
"firstName": "Camille",
"lastName": "Martin",
"smsConsent": {
"collectedAt": "2026-10-20T18:30:00.000Z",
"proofText": "I agree to receive offers from Le Bistrot by SMS. Unsubscribe at any time by replying STOP."
}
}'
The API answers 201 when it creates the contact and 200 when it completes an existing one, with the contact in its public shape (Contact). An existing contact is only completed: a field already filled is not overwritten. To change a field, use PATCH /v1/contacts/{contactId}, where a text field set to null is cleared and an absent field stays unchanged. Email and phone cannot be changed through the API.
Email or phone is required. The phone is written in the E.164 format (+33612345678) or in the French national format (0612345678).
The consent proof
A transmitted marketing consent, emailConsent or smsConsent, always carries its proof:
| Field | Content |
|---|---|
collectedAt | Date when the person agreed, in the ISO 8601 format, never in the future. |
proofText | Exact text shown to them when they agreed, 2,000 characters at most. |
Without either field, the API answers 400 invalid_request. An SMS consent requires a phone on the contact, an email consent requires an email. The recorded origin of the consent is api.
Creating a key that holds contacts:write also requires, in the app, the owner or admin to certify that the customers sent by the tool have given their explicit prior consent. This attestation is linked to every consent recorded with the key.
Your CRM identifier
Send externalId, the customer's identifier in your CRM or POS (1 to 128 characters, unique within a location). POST /v1/contacts first recognises the contact by this identifier, then by its email or phone: a customer whose email changed in your CRM stays the same contact. PATCH /v1/contacts/{contactId} changes or clears it (null), and GET /v1/contacts?externalId=crm-48213 finds the contact.
The identifier comes back in every survey response (survey_response.completed), in the contactExternalId field, with visitExternalId when the survey follows a visit: your CRM files the answer in the right record without keeping Ma Belle Note identifiers. If the location's after-visit survey has the "Also send to every new customer received through the API" option, a contact created with a consent receives the survey, with the configured delay, without waiting for a visit.
An opt-out always prevails
An email or a number that opted out, for instance after a STOP or a click on an unsubscribe link, never becomes consenting again through the API, even if the request transmits a consent. The call succeeds, but the consent is not granted: check optOuts and consents.<channel>.granted in the response, and do not contact the person through that channel. Excerpt of the response for an opted-out number:
{
"object": "contact",
"id": "ct9x2b5d8f1h4j7l0n3p6r9t2v5x8z1b",
"consents": {
"email": { "granted": false, "collectedAt": null, "source": null, "revokedAt": null },
"sms": {
"granted": false,
"collectedAt": "2026-03-02T12:10:00.000Z",
"source": "loyalty_wheel",
"revokedAt": "2026-09-14T08:02:51.000Z"
}
},
"optOuts": { "email": false, "sms": true }
}
Visits
POST /v1/visits records a customer's visit, from a till or a booking software. The customer is designated by contactId, or by their email or phone:
curl https://api.mabellenote.com/v1/visits \
-H "Authorization: Bearer $MBN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"establishmentId": "kh2n8p5r3t6v9b1c4d7f0g2j5k8m1n3p",
"externalId": "ticket-2026-10-20-0187",
"occurredAt": "2026-10-20T20:15:00.000Z",
"phone": "+33612345678"
}'
externalIdis the visit's identifier in your tool, for instance the ticket or booking number, from 1 to 128 characters. It is unique within a location: the sameexternalIdonly creates one visit, with or withoutIdempotency-Key. The first response is201, the following ones200with the existing visit.occurredAtdefaults to the time of the request.contactIdexcludesemailandphone.- An unknown customer is added to the customer base without consent: a visit grants none. To collect a consent, use
POST /v1/contactswith its proof.
Promo codes
POST /v1/promo-codes/redeem redeems, from a till, a promo code sent by a campaign or a scenario, with the same rules as a counter redemption in the app:
curl https://api.mabellenote.com/v1/promo-codes/redeem \
-H "Authorization: Bearer $MBN_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: till-1-ticket-2026-10-20-0187" \
-d '{ "code": "BIS-7KQ4", "establishmentId": "kh2n8p5r3t6v9b1c4d7f0g2j5k8m1n3p", "amountCents": 4850 }'
{
"object": "promo_code_redemption",
"promoCodeId": "pc3j6m9p2s5v8y1b4e7h0k3n6q9t2w5z",
"code": "BIS-7KQ4",
"establishmentId": "kh2n8p5r3t6v9b1c4d7f0g2j5k8m1n3p",
"contactId": "ct9x2b5d8f1h4j7l0n3p6r9t2v5x8z1b",
"offer": "A free dessert",
"campaignName": "Autumn win-back",
"issuedAt": "2026-10-01T09:00:00.000Z",
"expiresAt": "2026-10-31T09:00:00.000Z",
"redeemedAt": "2026-10-20T20:15:00.000Z",
"amountCents": 4850,
"currency": "EUR"
}
The code becomes redeemed. The customer's visit and the optional ticket amount, in cents, are recorded and count in the campaign's revenue attribution. The code is case-insensitive.
| Situation | Response |
|---|---|
| Code already redeemed | 409 promo_code_already_redeemed |
| Expired code | 409 promo_code_expired |
| Cancelled code | 409 conflict |
| Unknown code, or one from another location | 404 not_found |
| Too many attempts with the key | 429 rate_limited, with Retry-After |
A refused code cannot be forced through the API: forcing stays reserved to the app. Show the reason for the refusal to the till staff. Attempts are limited per key, on top of the general limit, to prevent guessing codes.
Reply drafts
A draft dropped or requested through the API shows up in the app like any draft, with its origin visible (source: "api" or "ai"). Nothing is published on Google by these two operations. A review already answered responds 409 conflict.
Drop a written draft
PUT /v1/reviews/{reviewId}/reply-draft drops the text of a reply written in your tool, like an edit made in the app. It creates the draft (201) or replaces the existing one (200):
curl -X PUT https://api.mabellenote.com/v1/reviews/jd7f3k2m9x8w1q4z6c5v3b2n1m0l9k8j/reply-draft \
-H "Authorization: Bearer $MBN_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "content": "Hello Jean, thank you for your feedback. We have talked it over with the evening team." }'
If the draft is already waiting in the autopilot queue, it stays there with your new text: this is the text the autopilot will publish. The edit is written to the autopilot log.
Ask the AI for a draft
POST /v1/reviews/{reviewId}/reply-drafts, with no body, asks the AI for a draft in the location's tone. If the review already has a draft, the AI regenerates it.
curl -X POST https://api.mabellenote.com/v1/reviews/jd7f3k2m9x8w1q4z6c5v3b2n1m0l9k8j/reply-drafts \
-H "Authorization: Bearer $MBN_API_KEY" \
-H "Idempotency-Key: draft-jd7f3k2m9x8w1q4z6c5v3b2n1m0l9k8j-1"
- Each generation uses one unit of the account's monthly AI credit, exactly as in the app. Once the credit is used up, the API answers
402 ai_credits_exhausted. - Each call counts as 5 in the key's rate limit.
- With
Idempotency-Key, at most one generation happens per completed idempotency key: use it so a network failure does not use two credits. - An AI assistant connected through MCP can request this draft too, with the
draft_replytool: see connect Claude or ChatGPT to your reviews.
The response is the draft (ReplyDraft), with sensitive: true when the review or the reply was deemed sensitive: publishing it will require a confirmation, in the app as through the API.
To read back the draft in progress, read the review (GET /v1/reviews/{reviewId}) with a key that has replies:write or ai:generate: its draft field carries the draft (ReplyDraft), or null when there is none. Without one of these scopes, the field is not present.
Updated on October 25, 2026

