Managing templates

A template is a message Meta has reviewed in advance. This page states exactly which actions this API supports — including the two it does not, which surprise people.

What you can and cannot do

ActionSupportedHow
Create and submit for reviewYesPOST /api/templates — one call does both.
Upload a header sampleYesPOST /api/templates/media, before creating.
List, filter and searchYesGET /api/templates
Read oneYesGET /api/templates/:id
Fetch the stored header sampleYesGET /api/templates/:id/media
Refresh one template’s statusYesPOST /api/templates/:id/sync
Refresh every template on a numberYesPOST /api/templates/waba/:wabaAccountId/sync
Import templates created outside WpAiYesPOST /api/templates/import
DeleteYesDELETE /api/templates/:id — removes it on Meta too.
Edit an existing templateNoThere is no PUT or PATCH. Delete and recreate.
Bulk createNoOne call per template.
Bulk deleteNoOne call per template.
Create a carousel or limited-time offerNoReadable if imported; not creatable here.
Parameterise buttonsNoNo dynamic URL suffixes or OTP copy-code buttons.
Everything on this surface. Nothing is hidden behind an undocumented route.

There is no edit route

Meta lets an approved template be edited; this API does not expose it. To change one, delete it and create it again. Note the ordering problem: a template is unique on (number, name, language), so recreating before the delete has propagated returns 409. Delete first, confirm, then create.

Creating one

POST/api/templates
Auth: API key or sessionPlan feature: campaigns

Creates it here and submits it to Meta in the same call. Returns with status PENDING.

cURL
curl -X POST https://api.wpai.co.in/api/templates \
  -H "X-API-Key: $WPAI_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "wabaAccountId": "68a1f3…",
    "name": "order_shipped",
    "language": "en_US",
    "category": "UTILITY",
    "components": [
      { "type": "HEADER", "format": "TEXT", "text": "Order update" },
      {
        "type": "BODY",
        "text": "Hi {{1}}, your order {{2}} has shipped.",
        "example": { "body_text": [["Priya", "4471"]] }
      },
      { "type": "FOOTER", "text": "Reply STOP to opt out" }
    ]
  }'

The name must be lowercase letters, numbers and underscores. Component rules are the substance of this — see /docs/templates/components for every combination.

201
{
  "template": {
    "_id": "68b7…",
    "name": "order_shipped",
    "language": "en_US",
    "category": "UTILITY",
    "status": "PENDING",
    "metaTemplateId": "1234567890",
    "components": [ /* as you sent them */ ]
  }
}

Statuses and categories

StatusMeaning
DRAFTCreated here but never submitted. Rare through the API.
PENDINGWith Meta for review. Usually minutes, sometimes hours.
APPROVEDSendable. The only status a send accepts.
REJECTEDMeta refused it. `rejectedReason` says why.
PAUSEDSuspended for poor quality. Fix the content and resubmit.
DISABLEDWithdrawn or deleted at Meta. Not sendable.

Categories are MARKETING | UTILITY | AUTHENTICATION, and the one Meta assigns may differ from the one you asked for. It decides the rate, so a re-categorisation changes what a send costs.

Learning the outcome

Review is asynchronous. There are two ways to find out what happened, and only one of them is a good idea.

Subscribe to the webhook — recommended

template.status fires the moment Meta reviews it, carrying the name, language, new status and the rejection reason. template.category fires on a re-categorisation, which is the one that changes your costs.

template.status delivery
{
  "event": "template.status",
  "timestamp": "2026-08-26T09:14:22.104Z",
  "data": {
    "name": "order_shipped",
    "language": "en_US",
    "metaTemplateId": "1234567890",
    "status": "APPROVED",
    "reason": ""
  }
}

Poll — the fallback

POST/api/templates/:id/sync
Auth: API key or sessionPlan feature: campaigns

Pulls the current status from Meta and returns the updated template.

Approval can take hours, so polling means either a long wait between checks or a lot of wasted calls. Prefer the webhook and use this to reconcile.

Listing and filtering

GET/api/templates
Auth: API key or sessionPlan feature: campaigns

?status= ?category= ?wabaAccountId= ?search= plus page and limit. Cached 120 seconds.

  • search matches the template name and its body text.
  • Omitting wabaAccountId returns templates across every number you own — excluding rows orphaned by a number that has since been removed.
  • Sorted by most recently updated. Default 20 per page, maximum 100.

Imported templates can contain more than you can create

A template imported from Meta may carry CAROUSEL, LIMITED_TIME_OFFER or CARD components, and OTP, MPM, CATALOG or FLOW buttons. They are readable and sendable by id; they are not creatable through this API.

Importing and syncing

POST/api/templates/import
Auth: API key or sessionPlan feature: campaigns

Body: { wabaAccountId }. Pulls every template Meta holds for that number and upserts it.

Use this when a number was managed elsewhere before, or after reconnecting one. It reads a single page of up to 200 templates and normalises Meta's wider vocabulary into ours — IN_APPEAL becomes PENDING, TRANSACTIONAL becomes UTILITY, and an unrecognised status becomes PENDING.

200
{ "ok": true, "total": 34, "imported": 12, "updated": 22 }

Deleting

DELETE/api/templates/:id
Auth: API key or sessionPlan feature: campaigns

Deleting removes every language variant

Meta deletes templates by name, not by id. Deleting the English variant of order_shipped removes the Hindi one at Meta too, while only the row you named disappears here. Re-import afterwards to bring the local list back in step.

A template already gone at Meta is not an error — the local row is cleaned up anyway. Any other Meta failure is returned with Meta's own status and the local row survives, so you can retry.