Base URL and Compatibility
https://api.dualhook.com/v25.0
Use Authorization: Bearer <DH_LIVE_KEY>. The supported paths intentionally match Meta Graph v25.0 so most integrations only change the host and credential. Never submit a Meta token or appsecret_proof; Dualhook applies both authorization and proof internally.
<PHONE_NUMBER_ID> and <WABA_ID> must exactly match the assets stored on the connection that owns the key. Object IDs are admitted only after connection/WABA ownership is proven.
Endpoint Matrix
| Operation | Method and path |
|---|---|
| Send message/read/typing request | POST /v25.0/<PHONE_NUMBER_ID>/messages |
| Upload media | POST /v25.0/<PHONE_NUMBER_ID>/media |
| Read media metadata | GET /v25.0/<MEDIA_ID> |
| Download media bytes | GET /v25.0/<MEDIA_ID>/content |
| Delete media | DELETE /v25.0/<MEDIA_ID> |
| Read phone identity and health | GET /v25.0/<PHONE_NUMBER_ID> |
| Read WABA identity and health | GET /v25.0/<WABA_ID> |
| Upload a template header sample | POST /v25.0/<WABA_ID>/message_template_media |
| List/create/delete templates | GET, POST, or DELETE /v25.0/<WABA_ID>/message_templates |
| Read/update one template | GET or POST /v25.0/<TEMPLATE_ID> |
| Read/update business profile | GET or POST /v25.0/<PHONE_NUMBER_ID>/whatsapp_business_profile |
| Read/update safe phone settings | GET or POST /v25.0/<PHONE_NUMBER_ID>/settings |
| List/create/update QR codes | GET or POST /v25.0/<PHONE_NUMBER_ID>/message_qrdls |
| Read/delete QR code | GET or DELETE /v25.0/<PHONE_NUMBER_ID>/message_qrdls/<CODE> |
Messages
Dualhook validates the bounded Graph message envelope and supports Meta's current
message types, including text, media, templates, reactions, contacts, locations,
interactive messages, Flows, catalogs, carousels, address messages, read status,
typing indicators, and BSUID-only sends using recipient instead of to.
Meta-owned nested message fields pass through so newly
documented message variants do not require a Dualhook release. Credential,
Graph-host, and top-level asset-routing controls are always rejected.
curl -X POST "https://api.dualhook.com/v25.0/<PHONE_NUMBER_ID>/messages" \
-H "Authorization: Bearer <DH_LIVE_KEY>" \
-H "Content-Type: application/json" \
-d '{
"messaging_product": "whatsapp",
"to": "12015550123",
"type": "text",
"text": {"body": "Hello"}
}'
{
"messaging_product": "whatsapp",
"contacts": [{"input": "12015550123", "wa_id": "12015550123"}],
"messages": [{"id": "wamid..."}]
}
For a user whose phone number is unavailable, omit to and send the BSUID or
parent BSUID through recipient:
{
"messaging_product": "whatsapp",
"recipient_type": "individual",
"recipient": "CO.2000162364208387",
"type": "text",
"text": {"body": "Hello"}
}
Every send requires type and either to or recipient. If both identifiers
are present, Dualhook forwards both and Meta gives to precedence. Dualhook
treats recipient as a bounded opaque identifier so Meta can evolve its syntax
without requiring a Runtime API release, and does not store or log it.
Dualhook never retries a message mutation automatically.
Media
Upload uses the normal Graph multipart shape:
curl -X POST "https://api.dualhook.com/v25.0/<PHONE_NUMBER_ID>/media" \
-H "Authorization: Bearer <DH_LIVE_KEY>" \
-F "messaging_product=whatsapp" \
-F "file=@photo.jpg;type=image/jpeg"
{"id": "<MEDIA_ID>"}
Read metadata and bytes:
curl "https://api.dualhook.com/v25.0/<MEDIA_ID>" \
-H "Authorization: Bearer <DH_LIVE_KEY>"
curl "https://api.dualhook.com/v25.0/<MEDIA_ID>/content" \
-H "Authorization: Bearer <DH_LIVE_KEY>" \
--output media.bin
Metadata uses the connection's phone ID internally. The returned url points back to Dualhook's /content route; a raw temporary Meta CDN URL is never exposed.
curl -X DELETE "https://api.dualhook.com/v25.0/<MEDIA_ID>" \
-H "Authorization: Bearer <DH_LIVE_KEY>"
Uploaded media is immediately bound to the connection. Inbound webhook media is adopted only after Meta verifies access with the bound phone ID. An object already owned by another connection returns 403.
Meta's optional phone_number_id scoping parameter is accepted on the metadata
and delete routes. It must equal the connection's stored phone-number ID or the
request returns 403; Dualhook applies that ID internally either way, so the
parameter can also be omitted.
Phone and WABA Health
Use management webhooks for immediate changes. Poll this endpoint only as
reconciliation, at most once every five minutes. Phone and WABA reads share a
maximum of six requests per minute per connection and 30 per minute per WABA.
Successful responses are cached for 60 seconds and include
X-Dualhook-Cache: hit or miss. Cache hits also include the standard Age
response header in seconds.
curl "https://api.dualhook.com/v25.0/<PHONE_NUMBER_ID>?fields=id,display_phone_number,quality_rating,status,name_status,whatsapp_business_manager_messaging_limit,health_status" \
-H "Authorization: Bearer <DH_LIVE_KEY>"
Phone fields are limited to:
id, display_phone_number, verified_name, code_verification_status, quality_rating, platform_type, is_on_biz_app, throughput, last_onboarded_time, is_official_business_account, account_mode, status, name_status, new_name_status, whatsapp_business_manager_messaging_limit, and health_status.
webhook_configuration is intentionally unavailable.
Meta's phone health_status already summarizes the related phone, WABA,
business, and app health. Do not make a second WABA health request when that
summary is all you need.
curl "https://api.dualhook.com/v25.0/<WABA_ID>?fields=id,name,message_template_namespace,account_review_status,business_verification_status,health_status" \
-H "Authorization: Bearer <DH_LIVE_KEY>"
WABA fields are limited to id, name, currency, timezone_id, message_template_namespace, account_review_status, business_verification_status, health_status, and is_enabled_for_insights.
{
"id": "<PHONE_NUMBER_ID>",
"quality_rating": "GREEN",
"status": "CONNECTED",
"name_status": "APPROVED",
"whatsapp_business_manager_messaging_limit": "TIER_10K"
}
Message Templates
List templates with an allowlisted field set:
curl "https://api.dualhook.com/v25.0/<WABA_ID>/message_templates?fields=id,name,language,status,category,rejected_reason,quality_score,health_status&status=APPROVED&limit=25" \
-H "Authorization: Bearer <DH_LIVE_KEY>"
Supported read fields include identity, language, status, current/previous/correct category, rejection reason, components, quality score and reasons, health/can-send state, parameter/display format, send TTL, update time, link-tracking choice, and primary-device delivery state.
Template lists may be filtered by validated name, status, category, and
language values.
For an IMAGE, VIDEO, or DOCUMENT header, first upload one sample file.
This Dualhook-owned WABA route performs Meta's two-step resumable upload
internally and returns only the resulting template header handle:
curl -X POST \
"https://api.dualhook.com/v25.0/<WABA_ID>/message_template_media?file_length=12345&file_type=image%2Fjpeg" \
-H "Authorization: Bearer <DH_LIVE_KEY>" \
-H "Content-Type: image/jpeg" \
--data-binary "@header.jpg"
{"h":"<HEADER_HANDLE>"}
file_length is the exact raw file size in bytes and must match the body.
file_type must exactly match Content-Type. Supported samples are JPEG/PNG
(5 MiB), MP4/3GPP (16 MiB), and PDF (100 MiB). Dualhook creates the app-scoped
upload session internally; callers cannot provide an app ID, upload-session ID,
offset, token, proof, or Graph host.
Create:
curl -X POST "https://api.dualhook.com/v25.0/<WABA_ID>/message_templates" \
-H "Authorization: Bearer <DH_LIVE_KEY>" \
-H "Content-Type: application/json" \
-d '{
"name": "shipping_update",
"language": "en_US",
"category": "UTILITY",
"components": [
{
"type": "HEADER",
"format": "IMAGE",
"example": {"header_handle": ["<HEADER_HANDLE>"]}
},
{
"type": "BODY",
"text": "Order {{1}} has shipped.",
"example": {"body_text": [["A123"]]}
}
]
}'
{"id": "<TEMPLATE_ID>", "status": "PENDING", "category": "UTILITY"}
The uploaded sample is for Meta's template review. When sending the approved
template through /messages, supply the actual per-message media as a URL or
phone-scoped media ID in the template header parameter.
Read and update an owned template:
curl "https://api.dualhook.com/v25.0/<TEMPLATE_ID>?fields=id,name,status,quality_score,rejected_reason" \
-H "Authorization: Bearer <DH_LIVE_KEY>"
curl -X POST "https://api.dualhook.com/v25.0/<TEMPLATE_ID>" \
-H "Authorization: Bearer <DH_LIVE_KEY>" \
-H "Content-Type: application/json" \
-d '{"components":[{"type":"BODY","text":"Order {{1}} is on its way."}]}'
Template names and languages are immutable on the update route. For the first
direct read of a template that has not yet been listed through Dualhook,
include at least one template-specific field such as name, status, or
language. Dualhook uses that hint to distinguish a template ID from a media
ID, verifies the template against the selected WABA, and records only minimal
ownership metadata.
Delete all languages with a name, one exact template with name plus hsm_id,
or at most 100 verified IDs with a standalone hsm_ids parameter:
curl -X DELETE "https://api.dualhook.com/v25.0/<WABA_ID>/message_templates?name=shipping_update&hsm_id=<TEMPLATE_ID>" \
-H "Authorization: Bearer <DH_LIVE_KEY>"
Business Profile
curl "https://api.dualhook.com/v25.0/<PHONE_NUMBER_ID>/whatsapp_business_profile?fields=about,address,description,email,profile_picture_url,websites,vertical" \
-H "Authorization: Bearer <DH_LIVE_KEY>"
{
"data": [{
"business_profile": {
"about": "Support Monday through Friday",
"email": "support@example.com",
"websites": ["https://example.com"],
"vertical": "PROF_SERVICES"
}
}]
}
curl -X POST "https://api.dualhook.com/v25.0/<PHONE_NUMBER_ID>/whatsapp_business_profile" \
-H "Authorization: Bearer <DH_LIVE_KEY>" \
-H "Content-Type: application/json" \
-d '{"about":"Support Monday through Friday","websites":["https://example.com"]}'
Dualhook injects messaging_product. Updates may include about, address, description, email, profile_picture_handle, up to two websites, and a supported vertical. The app-scoped profile-image upload-session endpoint is not exposed.
For a Coexistence connection, read access remains available but profile changes
must be made in the WhatsApp Business app. Runtime profile updates return 409.
Phone Settings and Calling
curl "https://api.dualhook.com/v25.0/<PHONE_NUMBER_ID>/settings" \
-H "Authorization: Bearer <DH_LIVE_KEY>"
The response is projected to safe user_identity_change, calling, and read-only storage_configuration fields. storage_configuration reports Meta Local Storage for data-residency evidence ({"status":"DEFAULT"} when it was never enabled, IN_COUNTRY_STORAGE_ENABLED plus data_localization_region once enabled). include_sip_credentials is rejected, and SIP passwords, servers, keys, and payload-encryption settings are never returned.
Each update must contain exactly one feature. storage_configuration is not writable through the Runtime API because Meta only accepts it on a deregistered number; see Local Storage.
curl -X POST "https://api.dualhook.com/v25.0/<PHONE_NUMBER_ID>/settings" \
-H "Authorization: Bearer <DH_LIVE_KEY>" \
-H "Content-Type: application/json" \
-d '{"user_identity_change":{"enable_identity_key_check":true}}'
curl -X POST "https://api.dualhook.com/v25.0/<PHONE_NUMBER_ID>/settings" \
-H "Authorization: Bearer <DH_LIVE_KEY>" \
-H "Content-Type: application/json" \
-d '{
"calling": {
"status": "ENABLED",
"call_icon_visibility": "DEFAULT",
"callback_permission_status": "ENABLED",
"audio": {"additional_codecs": ["PCMA", "PCMU"]},
"video": {"status": "DISABLED"}
}
}'
Success:
{"success": true}
Live call signaling (/calls), SDP, and call permissions are not part of this runtime surface.
Calling-setting updates are not available for Coexistence connections and return
409; manage those settings in the WhatsApp Business app. Safe identity-change
settings remain available.
QR Codes
List:
curl "https://api.dualhook.com/v25.0/<PHONE_NUMBER_ID>/message_qrdls?fields=code,prefilled_message,deep_link_url&limit=25" \
-H "Authorization: Bearer <DH_LIVE_KEY>"
Read one code:
curl "https://api.dualhook.com/v25.0/<PHONE_NUMBER_ID>/message_qrdls/<CODE>" \
-H "Authorization: Bearer <DH_LIVE_KEY>"
Create or update (include an existing 14-character code to update):
curl -X POST "https://api.dualhook.com/v25.0/<PHONE_NUMBER_ID>/message_qrdls" \
-H "Authorization: Bearer <DH_LIVE_KEY>" \
-H "Content-Type: application/json" \
-d '{"prefilled_message":"Tell me more","generate_qr_image":"SVG"}'
{
"code": "4O4YGZEG3RIVE1",
"prefilled_message": "Tell me more",
"deep_link_url": "https://wa.me/message/4O4YGZEG3RIVE1"
}
Delete:
curl -X DELETE "https://api.dualhook.com/v25.0/<PHONE_NUMBER_ID>/message_qrdls/<CODE>" \
-H "Authorization: Bearer <DH_LIVE_KEY>"
The prefilled message is limited to 140 characters. QR image format is PNG or SVG.
Pagination
Template and QR list routes accept limit plus one safe after or before cursor. Template limits are 1–100; QR limits are 1–25.
{
"data": [],
"paging": {
"cursors": {
"before": "<SAFE_CURSOR>",
"after": "<SAFE_CURSOR>"
}
}
}
Dualhook strips Meta's raw next and previous URLs. Send only the cursor value back to the same Dualhook route:
GET /v25.0/<WABA_ID>/message_templates?limit=25&after=<SAFE_CURSOR>
Input and Size Limits
| Input | Limit |
|---|---|
| Message JSON | 64 KiB |
| Template create/update JSON | 128 KiB |
| Template header JPEG/PNG sample | 5 MiB |
| Template header MP4/3GPP sample | 16 MiB |
| Template header PDF sample | 100 MiB |
| Business profile JSON | 16 KiB |
| Settings JSON | 32 KiB |
| QR JSON | 4 KiB |
| Audio/video media | 16 MiB |
| Documents | 100 MiB |
| JPEG/PNG | 5 MiB |
| WebP | 500 KiB |
JSON routes require Content-Type: application/json. Media upload requires a
valid multipart boundary, messaging_product=whatsapp, and a final file part
with a supported MIME type. The optional type part must match the file MIME
type when supplied. Content-Length is optional; the streamed file is still
stopped at its MIME-specific limit.
Template header sample upload uses the raw file body rather than multipart.
Both file_length and file_type are required, Content-Type must match, and
the streamed body must contain exactly the declared number of bytes.
Errors
Runtime-owned errors use a stable JSON envelope. For a symptom-first walkthrough of each one, see Runtime API Errors.
{"error":{"message":"Unsupported query parameter: owner_business_info"}}
Important statuses:
400invalid schema, field, cursor, query, or asset-shaped request.401invalid, revoked, expired, or quarantineddh_live_credential.403 connection_not_routablevalid key, blocked connection.403 asset_scope_conflictambiguous active ownership.403object ownership mismatch.409operation unavailable for the connection mode, such as a Coexistence-only profile or calling setting.413body or media limit exceeded.415unsupported content type or MIME type.429rate or failure-circuit limit, withRetry-After; this includes repeated authentication failures for one credential, returned in place of401once that credential is throttled. Cached revoked/expired credentials continue returning401without a database authentication lookup for up to 24 hours.502invalid or oversized upstream response/redirect.503runtime configuration or internal dependency unavailable.
Meta error codes, safe messages, and trace IDs remain useful, but credential-like fields and values are recursively sanitized. Meta error 190 also places the existing connection routing hold.
Unsupported Operations
The runtime deliberately excludes arbitrary Graph paths/versions/fields, webhook subscription or callback changes, app/business/partner/system-user/token management, other businesses' assets, registration/deregistration, phone/WABA deletion or migration, PIN and display-name changes, billing/funding, SIP credentials, encryption/storage settings, live calling, Flows management, certificates, catalogs, groups, contact-book management, marketing optimization, analytics, bulk template upsert/previews, and caller-controlled app-scoped upload sessions. Template header samples are supported only through the WABA-bound one-step route above.
These operations are partner-wide, lifecycle-destructive, credential-bearing, cross-business, difficult to bind to one connection, or need a separate permission and ownership design.