Skip to content
Start free

Contacts & opt-in

A contact is an end user reachable on one of your channels, identified by a stable Wabery id (aliased as contact_id). A phone number is optional: Meta can represent a WhatsApp username user only by a Business Scoped User ID (BSUID). Wabery namespaces that identity to the owning Meta business portfolio and updates it when Meta sends user_id_update. Before you can send proactively to a WhatsApp number — a to send outside an existing conversation — that contact needs an active opt-in record. Replies inside the 24-hour window do not.

Record the contact and its opt-in in one call. phone is E.164. optIn.source is required when you pass an optIn block — capture where and how consent was given so it holds up to a WhatsApp quality review.

const contact = await wabery.contacts.enroll({
phone: "+14155550100",
name: "Ada Lovelace",
preferredLanguage: "en", // BCP-47; resolves locale-specific flows
referenceId: "user_123", // your own id; echoed back as contact_reference
metadata: { plan: "pro" },
optIn: {
source: "checkout_form", // required within optIn
method: "web_form",
occurredAt: "2026-06-20T14:00:00Z",
consentText: "I agree to receive order updates on WhatsApp.",
},
});

Requires the contacts:write scope. project_id is optional — API keys are project-scoped, so it defaults to the key’s project and must match it if set.

A contact response:

{
"object": "contact",
"id": "contact_...",
"contact_id": "contact_...",
"phone": "+14155550100",
"preferred_language": "en",
"reference_id": "user_123",
"project_id": "project_...",
"status": "active"
}

Use id in new code; contact_id is a backward-compatible alias with the same value. reference_id is your correlation key — it surfaces as contact_reference on every webhook for this contact.

Import up to 100,000 rows (25 MB) asynchronously. The only required mapped column is phone; values must be E.164. Optional mappings cover name, preferred language, tags, and metadata. A tags cell uses | or ; separators.

Terminal window
wabery contacts import contacts.csv \
--phone-column phone \
--name-column name \
--language-column language \
--tags-column tags \
--consent '{"categories":["MARKETING"],"source":"signup_form","method":"web_form"}'
wabery contacts imports
wabery contacts import-status "import_id"

With the SDK:

const contactImport = await wabery.contacts.import({
file: new Blob([csvText], { type: "text/csv" }),
filename: "contacts.csv",
mapping: {
phone: "phone",
name: "name",
preferredLanguage: "language",
tags: "tags",
},
defaults: { tags: ["august-launch"] },
consent: {
categories: ["MARKETING"],
source: "signup_form",
method: "web_form",
occurredAt: "2026-07-20T12:00:00Z",
consentText: "Send me product announcements on WhatsApp.",
},
});
const status = await wabery.contacts.getImport(contactImport.id);

Poll until status is completed. The response reports created, updated, failed, and processed row counts. Rejected rows are available from GET /contact-imports/{id}/errors.

Consent is category-specific. Add it after enrollment when it was captured in a separate product flow:

await wabery.contacts.addConsent("contact_...", {
categories: ["MARKETING"],
source: "account_preferences",
method: "web_form",
occurredAt: "2026-07-20T12:00:00Z",
consentText: "Send me product announcements on WhatsApp.",
evidence: { formVersion: "v3" },
});
Terminal window
wabery contacts consent "contact_..." \
--categories MARKETING \
--source account_preferences \
--method web_form

Suppress a contact globally by omitting the category, or suppress only one category:

await wabery.contacts.suppress("contact_...", {
category: "MARKETING",
reason: "Requested by customer",
});
Terminal window
wabery contacts suppress "contact_..." \
--category MARKETING --reason "Requested by customer"

Inbound STOP, UNSUBSCRIBE, CANCEL, END, or QUIT creates a suppression automatically. Recording a later consent does not lift the suppression. After independently verifying a genuine re-enrollment request, lift the suppression explicitly; do not manufacture a consent record to bypass an opt-out.

metadata is shallow-merged into the existing value; preferredLanguage: null clears it. Requires contacts:write.

await wabery.contacts.update("contact_...", {
preferredLanguage: "es",
metadata: { linked: true }, // merged, not replaced
});

Cursor-paginated with limit and startingAfter (see Pagination). Requires contacts:read.

const { data, has_more } = await wabery.contacts.list({ limit: 50 });
// Or stream every page without managing the cursor:
for await (const contact of wabery.contacts.listAll()) {
console.log(contact.id, contact.phone);
}

unenroll deletes the project contact record. Pass erase: true to also erase retained personal data, including stored broadcast personalization, flow submissions and dispatches, flow-event details, and connector invocation payloads (requires the contacts:erase scope) — use it for GDPR / right-to-be-forgotten requests.

await wabery.contacts.unenroll("contact_...", { erase: true });

Enrollment is a server-side action with a secret key — you already hold the number and consent. To let a customer opt in from your app without a secret key, mint a browser-safe registration intent instead: they tap a wa.me link, message your number, and Wabery links the WhatsApp identity to your customer_reference.

Sending messages

Send text, media, templates, and interactive messages to a contact.

Registration intents

Browser-safe opt-in links that link a WhatsApp identity to your user id.

Conversations & history

List a contact’s threads and read message history.

WhatsApp broadcasts

Send an approved template to a consent-eligible audience.