Generate or import Flow endpoint key
Puts the data_exchange keypair on ONE number, by one of TWO verbs told apart by a single field: send privateKey and it IMPORTS the key you already own, leave it out and it GENERATES a fresh pair. Answers 200 with the same shape the GET returns, plus replaced.
- GENERATE — no
privateKey. Mints the pair, stores the private half encrypted, and REGISTERS the public half with Meta. - IMPORT —
privateKey, optionally withpassphrase. Stores a key you ALREADY own, and ⛔ REGISTERS NOTHING WITH META.
⛔ GENERATING IS A DESTRUCTIVE OPERATION WEARING THE FACE OF A CREATION. Meta holds exactly ONE public key per phone id: registering a second one does not add it, it REPLACES the first, and every data_exchange Flow on that number stops decrypting at once. From the customer’s side the symptom is a form that no longer opens, with no error anywhere on our side — the endpoint simply answers 421 to a body encrypted for a key we no longer hold. The private half stored here is overwritten at the same moment and cannot be restored.
⛔ IMPORTING REGISTERS NOTHING WITH META, and that is the entire point of it. Your public key is already registered — often by a system that predates us — and Meta holds exactly one key per phone id, so registering here would replace the very setup the import exists to preserve. What makes it safe is a READ: the public half is derived from the private one you sent and compared with the key Meta reports for the number.
- they agree → stored,
uploadedAtstamped,metaStatus: "VALID". - they disagree → 400
FLOW_ENDPOINT_KEY_IMPORT_MISMATCH, and NOTHING is written. Storing it would leave a number reportingconfigured: truethat decrypts nothing. - Meta could not be asked, or holds no key → stored anyway, with
uploadedAt: nullandmetaStatusUNKNOWN/NOT_SET. That null IS the “unverified” signal and you have to read it: a 200 alone does not tell the two apart.
A passphrase-protected PEM is accepted; the key is opened once, here, and stored normalised. The PEM must be RSA of 2048 bits or more — Meta encrypts data_exchange with RSA/OAEP.
⛔ confirm IS NOT ALWAYS REQUIRED. It is required exactly when the call REPLACES A LIVE KEY — and that rule is the same for BOTH verbs. One state check decides it: a key of ours is already live at Meta (uploadedAt is not null). The FIRST setup never asks, whichever verb you use, and neither does retrying a pair Meta never accepted (uploadedAt: null) — that call re-uploads the SAME stored public half and converges instead of replacing anything, so there is nothing to destroy. When it IS asked for, the check is === true, so "true", 1 and {} are all refused.
⚠️ Importing is gated too, and what it puts at risk is OUR side, not Meta’s. An import registers nothing at Meta, but it overwrites the private half stored here — the key WE decrypt with. If the number already held a working key and Meta cannot confirm the imported one (it being unreachable, say), the imported key is stored anyway and every data_exchange request on that number stops decrypting, with nothing changed at Meta to point at. So the two verbs share the flag and answer with DIFFERENT codes: FLOW_ENDPOINT_KEY_REQUIRES_CONFIRMATION when generating (what is at stake is the registration at Meta) and FLOW_ENDPOINT_KEY_IMPORT_REQUIRES_CONFIRMATION when importing (what is at stake is the private half kept here).
⚠️ replaced is in the payload because a 200 alone does not say which of the two things happened. replaced: false is “a key now exists where none was live”; replaced: true is “the key that was working is gone, and every Flow that was answering with the old pair is now answering with the new one”.
The body accepts three fields — confirm, privateKey and passphrase — and anything else is 400 FLOW_UNKNOWN_FIELDS. passphrase on its own is REFUSED, 400 FLOW_ENDPOINT_KEY_PASSPHRASE_ORPHAN, and never ignored: a mistyped privateKey would otherwise fall through into GENERATE, which registers a fresh key at Meta and replaces whatever was there — the most destructive outcome this route has, reached by a typo and reported as success. A privateKey that is not a non-empty string, or a passphrase that is not a string, is 400 FLOW_ENDPOINT_KEY_IMPORT_INVALID, answered before any crypto runs. A PEM that cannot be used is a 400 whose code names the repair: FLOW_ENDPOINT_KEY_PASSPHRASE_REQUIRED, FLOW_ENDPOINT_KEY_PASSPHRASE_WRONG, or FLOW_ENDPOINT_KEY_PEM_INVALID (unreadable, not RSA, or under 2048 bits).
A truncated body is not read as an empty one — the payload is parsed from raw text, so {"confirm": true (one missing brace) is 400 FLOW_BODY_INVALID rather than a confusing “confirmation required”. No body at all stays legitimate, and means GENERATE. Naming the number in the body — whatsappNumberId, numberId or wabaId — has a refusal of its own, 400 FLOW_NUMBER_FROM_KEY, because the number comes from the credential. Permission flows:manage.
Requires a number-scoped key. A tenant-scoped key must name the number with the x-whatsapp-number-id header, or it gets 403 TENANT_SCOPE_NOT_ALLOWED.
Authorizations
Your ps_ API key
Path Parameters
The id of the number the key is bound to — the id (or instance id) GET /v1/numbers returns. It is CHECKED against the credential's own number, never used to look one up.
Body
Must be the boolean true, exactly. ⛔ NOT always required: it is required exactly when the call REPLACES A LIVE KEY — i.e. when a key of ours is already live at Meta (uploadedAt is not null) — and that rule holds for BOTH verbs, generating and importing alike. The first setup does not ask, and neither does retrying a pair Meta never accepted. What it acknowledges differs by verb: when generating, that the key registered at Meta is REPLACED and every data_exchange Flow of this number stops decrypting; when importing, that the private half stored here is REPLACED, which breaks decryption on our side if Meta cannot confirm the imported key.
true true
The PEM of a private key you ALREADY own, whose public half Meta has registered for this number. ⛔ Its PRESENCE is what turns this POST from GENERATE into IMPORT: sent, nothing is registered with Meta and only the stored private half changes; omitted, a fresh pair is minted and its public half REGISTERED at Meta. Must be a non-empty string — privateKey: 42 is 400 FLOW_ENDPOINT_KEY_IMPORT_INVALID and is never coerced. Send the whole .pem file contents, -----BEGIN …----- and -----END …----- lines included. RSA, 2048 bits or more; a passphrase-protected PEM is accepted alongside passphrase. If its public half is not the one Meta holds, the answer is 400 FLOW_ENDPOINT_KEY_IMPORT_MISMATCH and nothing is written.
1"-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQ...\n-----END PRIVATE KEY-----\n"
The passphrase that opens privateKey, when the PEM is encrypted. Optional, and only ever alongside privateKey: ⛔ sent on its own it is REFUSED with 400 FLOW_ENDPOINT_KEY_PASSPHRASE_ORPHAN rather than ignored, because a mistyped privateKey would otherwise fall through into GENERATE and replace the registration at Meta. Must be a string. An encrypted PEM with no passphrase is 400 FLOW_ENDPOINT_KEY_PASSPHRASE_REQUIRED; a passphrase that does not open it is 400 FLOW_ENDPOINT_KEY_PASSPHRASE_WRONG — two codes, because they are two different repairs.
"the-passphrase-that-opens-the-pem"
Response
A keypair now stands on the number. GENERATE also registered the public half with Meta; IMPORT registered NOTHING — there, uploadedAt: null with metaStatus UNKNOWN/NOT_SET means the key was STORED and Meta did not confirm it, which is the one thing a 200 alone does not tell you. replaced says whether a key that was LIVE has just been displaced — a 200 alone does not