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:

ScopeOperations
contacts:writePOST /v1/contacts, PATCH /v1/contacts/{contactId}
visits:writePOST /v1/visits
promo_codes:redeemPOST /v1/promo-codes/redeem
replies:writePUT /v1/reviews/{reviewId}/reply-draft
ai:generatePOST /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_only on a read-only account, for instance after the trial ends.
  • Every write accepts the Idempotency-Key header, 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 answers 400 invalid_request.
  • Writes produce the same events as the app, with the api origin: a contact created through the API gives a contact.created event with the api origin.

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).

A transmitted marketing consent, emailConsent or smsConsent, always carries its proof:

FieldContent
collectedAtDate when the person agreed, in the ISO 8601 format, never in the future.
proofTextExact 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"
  }'
  • externalId is 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 same externalId only creates one visit, with or without Idempotency-Key. The first response is 201, the following ones 200 with the existing visit.
  • occurredAt defaults to the time of the request.
  • contactId excludes email and phone.
  • An unknown customer is added to the customer base without consent: a visit grants none. To collect a consent, use POST /v1/contacts with 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.

SituationResponse
Code already redeemed409 promo_code_already_redeemed
Expired code409 promo_code_expired
Cancelled code409 conflict
Unknown code, or one from another location404 not_found
Too many attempts with the key429 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." }'
Draft in the autopilot queue

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_reply tool: 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