WhatsApp API Platform

Template

Embed the WhatsApp template creation wizard, prefill it from JSON, and react to created and cancelled events.

Mounts the template-creation wizard as an iframe. There is no list or edit surface — the embed always opens on a fresh template, so it's ideal for an in-app "Create template" flow.

Pass a template to open the wizard with every field already filled in. See Prefilling from JSON, or jump to a worked example for your template type.

Quick start

<div id="embed" style="height: 720px"></div>
<script src="https://pingmate.app/embed.js" defer></script>
<script>
  Embed.render("template", {
    target: "#embed",
    authToken: "<jwt>", // or apiKey: "pm_xxx"
    theme: "system",
    on: {
      ready: function () {
        console.log("creator ready");
      },
      created: function (m) {
        console.log("template created", m.templateId);
      },
      cancelled: function () {
        console.log("creator cancelled");
      },
    },
  });
</script>

Options

On top of the common options:

OptionTypeDescription
templateobject or JSON stringOpens the wizard on this template instead of a blank one.

Prefilling from JSON

Hand the embed a template as JSON and the wizard opens with every field filled in: name, language, category, header, body and its sample values, footer, buttons, carousel cards, and the authentication settings. The user still reviews and submits it, so this is a starting point, not a silent create.

Embed.render("template", {
  target: "#embed",
  authToken: "<jwt>",
  template: {
    name: "order_confirmed",
    language: "en",
    category: "utility",
    body: {
      text: "Hi {{1}}, we have your order {{2}}. We will message you the moment it ships.",
      examples: { body_positional: [["Ananya", "#PM-4821"]] },
    },
    footer: { text: "Reply STOP to opt out" },
  },
});

The template travels to the frame over postMessage, not the URL, so there is no practical size limit and nothing lands in browser history or your server logs.

Every field is optional. A partial template is a legitimate starting point, and anything you leave out keeps the wizard's default.

Examples by template type

Each tab below is a complete template value. Drop it straight into render or setTemplate.

Meta's own limits still apply at submit time (60 characters for a text header, 1024 for a body, 25 per button label, and so on). Templates has the full rule tables.

Body only. This is the smallest useful template: body text is the one component Meta requires.

{
  "name": "order_confirmed",
  "language": "en",
  "category": "utility",
  "body": {
    "text": "Hi {{1}}, we have your order {{2}}. We will message you the moment it ships.",
    "examples": { "body_positional": [["Ananya", "#PM-4821"]] }
  },
  "footer": { "text": "Reply STOP to opt out" }
}

body_positional is an array of rows, one value per variable. Sample values are what Meta reviews the template against, so send real-looking ones.

With a text header

A text header takes exactly one variable, numbered independently of the body. Its samples go in examples.positional, not body_positional.

{
  "name": "cart_reminder",
  "language": "en",
  "category": "marketing",
  "header": {
    "format": "TEXT",
    "text": "{{1}}, your cart is still open",
    "examples": { "positional": ["Ananya"] }
  },
  "body": {
    "text": "You left {{1}} in your cart. Checkout takes 30 seconds.",
    "examples": { "body_positional": [["2 items"]] }
  },
  "footer": { "text": "Reply STOP to opt out" }
}

Set format to IMAGE, VIDEO, or DOCUMENT. The file itself is attached per send, so the header only needs its format.

{
  "name": "new_drop_live",
  "language": "en",
  "category": "marketing",
  "header": { "format": "IMAGE" },
  "body": {
    "text": "The {{1}} drop is live. Early access ends tonight.",
    "examples": { "body_positional": [["Monsoon"]] }
  },
  "buttons": [
    { "type": "URL", "text": "Shop the drop", "url": "https://shop.example.in/new" }
  ]
}

Documents carry a display filename:

{
  "name": "invoice_ready",
  "language": "en",
  "category": "utility",
  "header": { "format": "DOCUMENT", "file_name": "invoice.pdf" },
  "body": {
    "text": "Invoice {{1}} for {{2}} is attached.",
    "examples": { "body_positional": [["#INV-2291", "₹2,499"]] }
  }
}

media_url is optional

A media_url on the header is accepted and fills the wizard's preview, but the wizard has no field for editing it. Leave it out unless you are round-tripping a template you already stored.

A location header carries a default pin. WhatsApp takes the real pin as a send-time parameter, so this is only the fallback for sends that do not carry their own.

{
  "name": "pickup_ready",
  "language": "en",
  "category": "utility",
  "header": {
    "format": "LOCATION",
    "location": {
      "latitude": 19.076,
      "longitude": 72.8777,
      "name": "PingMate Store, Bandra",
      "address": "Linking Road, Bandra West, Mumbai 400050"
    }
  },
  "body": {
    "text": "Order {{1}} is ready for pickup until 8pm today.",
    "examples": { "body_positional": [["#PM-4821"]] }
  }
}

Send both coordinates together with a name or an address. A half-filled pin is rejected. To require a pin on every send, omit location entirely and pass { "format": "LOCATION" }.

Four types: QUICK_REPLY, URL, PHONE_NUMBER, and COPY_CODE. Keep quick replies together as one contiguous group when you mix them with the others.

{
  "name": "cod_confirmation",
  "language": "en",
  "category": "utility",
  "body": {
    "text": "Hi {{1}}, confirm your COD order {{2}} for {{3}}?",
    "examples": { "body_positional": [["Ananya", "#PM-4821", "₹2,499"]] }
  },
  "buttons": [
    { "type": "QUICK_REPLY", "text": "Confirm order" },
    { "type": "QUICK_REPLY", "text": "Cancel" },
    {
      "type": "URL",
      "text": "Track order",
      "url": "https://shop.example.in/orders/{{1}}",
      "examples": ["PM-4821"]
    },
    {
      "type": "PHONE_NUMBER",
      "text": "Call support",
      "phone_number": "+919876543210"
    }
  ]
}

A {{1}} in a URL makes that button per-recipient. Its sample goes in the button's own examples array.

Coupon code

{
  "name": "first_order_coupon",
  "language": "en",
  "category": "marketing",
  "body": {
    "text": "Here is {{1}} off your first order.",
    "examples": { "body_positional": [["10%"]] }
  },
  "buttons": [
    { "type": "COPY_CODE", "text": "Copy code", "examples": ["FIRST10"] }
  ]
}

DYNAMIC_LINK is a PingMate button, not a Meta one. It is a URL button on a fixed tracking host whose destination is resolved per recipient at send time, so it needs nothing but a label.

{
  "name": "winback_offer",
  "language": "en",
  "category": "marketing",
  "body": {
    "text": "We saved your size in the {{1}} restock.",
    "examples": { "body_positional": [["Monsoon"]] }
  },
  "buttons": [{ "type": "DYNAMIC_LINK", "text": "Shop now" }]
}

The tracking URL and its sample are filled in for you. A plain URL button already pointing at that host is recognised as a dynamic link too, so templates you exported from PingMate round-trip unchanged.

Authentication templates have no editable header, body, footer, or buttons. Meta generates the copy. You choose the settings.

{
  "name": "login_otp",
  "language": "en",
  "category": "authentication",
  "add_security_recommendation": true,
  "code_expiration_minutes": 10,
  "copy_code_button_text": "Copy code"
}
FieldEffect
add_security_recommendationAppends Meta's "do not share this code" line.
code_expiration_minutes1 to 90. Adds the expiry line to the footer.
copy_code_button_textLabel on the copy button, max 25 characters.

Any header, body, footer, or buttons you send alongside "category": "authentication" are dropped on submit, so there is no need to strip them first.

Named variables

The examples above use positional variables ({{1}}, {{2}}). To use named ones, set parameter_format and put the samples in examples.named.

{
  "name": "shipping_update",
  "language": "en",
  "category": "utility",
  "parameter_format": "named",
  "body": {
    "text": "Hi {{customer_name}}, order {{order_id}} ships today.",
    "examples": {
      "named": [
        { "param_name": "customer_name", "example": "Ananya" },
        { "param_name": "order_id", "example": "#PM-4821" }
      ]
    }
  }
}

Pick one format per template. Meta does not allow mixing positional and named variables in the same template.

Accepted shapes

You do not have to reshape what you already store. All of these are read:

  • This API's shape. The objects above, or a whole row from GET /waba/templates ({ generic_content: … }), or the response that wraps it ({ data: … }).
  • Meta's Cloud API shape. A flat components array exactly as GET /{waba-id}/message_templates returns it, samples and all.
  • A JSON string of either.
// Straight from Meta, unmodified.
Embed.render("template", {
  target: "#embed",
  authToken: "<jwt>",
  template: {
    name: "Order Shipped",
    language: "en_US",
    category: "UTILITY",
    components: [
      {
        type: "HEADER",
        format: "IMAGE",
        example: { header_handle: ["https://cdn.example.in/box.jpg"] },
      },
      {
        type: "BODY",
        text: "Hi {{1}}, order {{2}} is on its way.",
        example: { body_text: [["Ananya", "#PM-4821"]] },
      },
      { type: "FOOTER", text: "Reply STOP to opt out" },
      {
        type: "BUTTONS",
        buttons: [
          {
            type: "URL",
            text: "Track",
            url: "https://shop.example.in/t/{{1}}",
            example: ["PM-4821"],
          },
        ],
      },
    ],
  },
});

Meta's field names are mapped for you: example.header_handle becomes the header media, example.body_text becomes the body samples, and a button's bare example array becomes its samples.

What gets normalised

Two fields are corrected rather than taken literally:

  • name is folded to the lowercase-and-underscores form Meta requires. A display name like "Order Shipped" arrives as order_shipped instead of a validation error the user cannot clear.
  • template_type is derived from what you send, never read from the input, so the type selector is never left on a value the wizard does not offer.

Upper-case categories, formats, and button types are accepted and cased to match, so "UTILITY" and "utility" both work.

If the JSON cannot be read as a template at all, the wizard stays blank and shows an error instead of half-filling itself.

Prefilling later

setTemplate does the same thing on a mounted embed, and can be called as often as you like. Each call re-fills the form and returns it to step one. It is safe to call before ready; the template is held until the wizard mounts.

var instance = Embed.render("template", {
  target: "#embed",
  authToken: "<jwt>",
});

instance.setTemplate(myTemplateJson);

Declarative mount

On a data-embed mount, pass the JSON as a string:

<div
  data-embed="template"
  data-auth-token="<jwt>"
  data-template='{"name":"order_confirmed","body":{"text":"On its way."}}'
  style="height: 720px"
></div>

Events

EventFires when
readyThe wizard has loaded and authenticated.
createdA template was created. Payload includes templateId.
cancelledThe user dismissed the wizard without creating a template.

Teardown

render returns an instance; call destroy() when you remove the embed (required in SPAs):

var instance = Embed.render("template", {
  target: "#embed",
  authToken: "<jwt>",
});

// later…
instance.destroy();

How is this guide?

On this page