Your Swift service already knows the order shipped, the invoice is overdue, the appointment is tomorrow. Getting that fact into WhatsApp is where it stalls, usually behind a Business Solution Provider contract nobody wants to sign for a few hundred notifications a day.
Here is the plain version. A whatsapp api swift integration is one authenticated POST to a REST endpoint, sending from the WhatsApp number you already own over WhatsApp Web or a managed 24/7 gateway. It is not Meta's Cloud API, so there are no templates, no Business verification, and no per-message billing anywhere below. Consent stays yours, and nobody can promise a number will never be actioned.
What is a WhatsApp API integration in Swift, really?
It is a single HTTP call from your own code to a number you already control. You POST to a REST endpoint, authenticate with a bearer key, and the message leaves from your linked WhatsApp account over WhatsApp Web or a hosted gateway. This is the own-number model, not Meta's Cloud API, so no templates or per-message fees apply.
The distinction changes your whole integration. On the Cloud API path you register a number with Meta, get it approved, submit message templates for review, and pay per message: Meta deprecated conversation-based pricing on 1 July 2025, so it now bills per message sent. On the own-number path you send from a phone your customers already have saved, in plain text, with a bt_live_ key.
Swift shares no runtime or package ecosystem with the other languages in this series, so nothing carries over line for line. But the wire shape is identical: it is the same REST call another statically-typed backend makes. The Go guide, the Rust guide, and the Kotlin guide walk the same first send in their own idioms. This piece takes the idiomatic-Swift path: async/await, one reused URLSession (or HTTPClient on the server), Codable structs, and Optional doing the null-handling the compiler enforces. The auth model is the own-number REST API guide; this piece stays on the Swift mechanics.
One thing from the start: sending from your own number is not a licence to blast. A loop firing unsolicited messages is the fastest way to get a number flagged under WhatsApp's Business Policy. Consent is yours to collect.
What do you need before your first send from Swift (an iOS app or a server-side Vapor service)?
Three things: a recent Swift toolchain (Swift 5.9+, with Swift 6 concurrency a safe floor), a WhatsApp number already linked to your Blueticks account, and a bt_live_ key. The recipient goes in the URL path as an E.164 number like +15551234567, not in the JSON body. That single detail causes most first-attempt failures.
One honest note on dependencies. On Apple platforms, Foundation ships URLSession and Codable, so an iOS or macOS first send needs no third-party package, the "no extra deps" opening the Go guide could claim and Rust could not. On a server, the idiomatic client is swift-server's async-http-client, the one package a Vapor service adds.
The pre-flight, in order:
- Mint a key. Open dev.blueticks.co, sign in, create a key, copy it once. Keys are bearer tokens, so read them at runtime with
ProcessInfo.processInfo.environment["BLUETICKS_API_KEY"], never a committed file. - Link a number. Blueticks drives your number, not a Meta-provisioned one. If you already connected a phone in the app, you are done.
- Check what you do not need. No Meta Business verification, no message templates, no per-message billing.
Now the security line specific to Swift, and non-negotiable. Never embed a bt_live_ key in a distributed iOS app binary. Anything in an app bundle is extractable, and a leaked key sends on your number. An iOS app must call your own backend, which holds the key and calls Blueticks. The Swift-on-the-server half of this guide is that backend. A client-side key embed is a leak, not a shortcut.
And the trap that catches everyone. The endpoint is POST /v1/scheduled-messages/{chatId} on https://api.blueticks.co, and {chatId} is a path segment. A recipient is an E.164 number with a leading +, and a bare + in a path is ambiguous, so it has to be percent-encoded as %2B. You can also target a chat id directly, such as 120363...@g.us for a group. The same first send in shell, Python, and Node lives in the language-agnostic walkthrough if you want to sanity-check the request outside Swift first.
How do you send your first WhatsApp message from Swift with async/await, URLSession, and Codable?
Build the body from an Encodable struct with CodingKeys, set an Authorization: Bearer header, and POST to the escaped path with URLSession inside an async function. Omit sendAt and it sends immediately. This is the send whatsapp message swift baseline, and on Apple platforms it needs no third-party package: unlike Rust there is no keyword clash, since type is a perfectly good Swift property name.
import Foundation
struct SendBody: Encodable {
let type: String
let text: String
// A nil Optional is simply not encoded, so omitting sendAt sends now.
let sendAt: String?
// camelCase wire fields map 1:1 here, but declaring CodingKeys makes
// the contract explicit and survives a property rename later.
enum CodingKeys: String, CodingKey {
case type, text, sendAt
}
}
func sendText(apiKey: String, recipient: String, text: String) async throws -> (Int, Data) {
// The recipient is a PATH segment: '+' -> %2B (see the escaping section).
let url = URL(string:
"https://api.blueticks.co/v1/scheduled-messages/\(encodePathSegment(recipient))")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("invoice-4471-reminder", forHTTPHeaderField: "Idempotency-Key")
// Build JSON from the struct, never string interpolation: a quote in a
// customer name would break a hand-concatenated payload.
request.httpBody = try JSONEncoder().encode(
SendBody(type: "text", text: text, sendAt: nil)
)
// URLSession.shared reuses one connection pool (see the next section).
let (data, response) = try await URLSession.shared.data(for: request)
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
return (status, data)
}
Three things about that body. type is required and validation-only, so a bare {"text":"hi"} is rejected with a 400. Text tops out at 4,096 characters. A 2xx that returns a resource is wrapped in {"success":true,"data":{...}}, so the id lives at data.id, never at the top level. Decode the envelope.
The server-side variant is the same request through async-http-client, the whatsapp api vapor path you drop into a route:
import AsyncHTTPClient
import NIOCore
// `httpClient` is ONE shared client built at boot (see the next section).
func sendTextVapor(_ httpClient: HTTPClient, apiKey: String,
recipient: String, text: String) async throws -> HTTPClientResponse {
var request = HTTPClientRequest(
url: "https://api.blueticks.co/v1/scheduled-messages/\(encodePathSegment(recipient))")
request.method = .POST
request.headers.add(name: "Authorization", value: "Bearer \(apiKey)")
request.headers.add(name: "Content-Type", value: "application/json")
let payload = try JSONEncoder().encode(SendBody(type: "text", text: text, sendAt: nil))
request.body = .bytes(payload)
return try await httpClient.execute(request, timeout: .seconds(15))
}

Why reuse ONE URLSession (or HTTPClient) instead of creating one per send?
Reuse one client for the process lifetime. URLSession.shared already holds a connection pool, so a fresh URLSession(configuration:) per send throws that pool away and pays a new TLS handshake on every message. That is the footgun the URLSession docs warn about: it looks fine in a demo and starves you under load. A second Swift sting: a custom URLSession with a delegate leaks unless you call finishTasksAndInvalidate(), because the session retains its delegate for life. Prefer URLSession.shared or one long-lived session held on your app state or an actor.
On server-side Swift the sting is sharper still. async-http-client's HTTPClient must be explicitly shut down or it leaks event-loop threads and file descriptors, and a deinit that drops one without shutting it down will trap. The async-http-client README is blunt: create one HTTPClient, reuse it, and call try await httpClient.shutdown() once at process shutdown. In Vapor, reach for app.http.client.shared.
// Build ONE client at boot, store it, shut it down once.
let httpClient = HTTPClient(eventLoopGroupProvider: .singleton)
// ... reuse httpClient for every send for the whole process lifetime ...
// At shutdown, exactly once:
try await httpClient.shutdown()
The anti-pattern is creating the client inside the send function or inside a loop:
// WRONG on the server: a new HTTPClient per send. Leaks event-loop threads
// and FDs, and traps on deinit if never shut down.
func sendBad(text: String) async throws {
let client = HTTPClient(eventLoopGroupProvider: .singleton) // leaks every call
var req = HTTPClientRequest(url: "https://api.blueticks.co/v1/scheduled-messages/%2B15551234567")
req.method = .POST
req.body = .bytes(Data(#"{"type":"text","text":"\#(text)"}"#.utf8))
_ = try await client.execute(req, timeout: .seconds(15))
// client never shut down -> resource leak
}
How do you escape the recipient path segment without silently sending to the wrong number?
The leading + in an E.164 number must be percent-encoded to %2B because it sits in the URL path, and Swift's reflexes get this wrong silently. addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) does not escape +, because it treats + as an allowed path sub-delimiter, so +15551234567 stays as-is and resolves to the wrong recipient. .urlQueryAllowed leaves + untouched too, and assigning to URLComponents.path re-encodes the string, double-encoding a pre-escaped %2B into %252B. You would never spot it in a quick test, because the recipient still looks like a phone number.
The fix is to percent-encode the recipient against a restricted set so + becomes %2B, a space becomes %20, and a group chat id like 120363...@g.us survives, then build the URL by string rather than by assigning to URLComponents.path. This follows RFC 3986 path-segment rules.
import Foundation
// Start from .urlPathAllowed, then REMOVE the sub-delimiters that must be
// escaped in a recipient: '+' -> %2B and '@' -> %40. Space is not in
// .urlPathAllowed, so it already encodes to %20. Alphanumerics survive.
private let recipientAllowed: CharacterSet = {
var set = CharacterSet.urlPathAllowed
set.remove(charactersIn: "+@/")
return set
}()
func encodePathSegment(_ recipient: String) -> String {
recipient.addingPercentEncoding(withAllowedCharacters: recipientAllowed) ?? recipient
}
// +15551234567 -> %2B15551234567
// 120363...@g.us -> 120363...%40g.us
If you want to reason about it even less, CharacterSet.alphanumerics is a safe over-encoding floor: it escapes everything that is not a letter or digit, and the server decodes it back. The one thing you must not do is hand the recipient to .urlPathAllowed untouched or drop it into URLComponents.path and trust it.
How do you model the response and survive a null waMessageKey with Optional?
Model the envelope with Decodable structs and declare let waMessageKey: WaMessageKey?. On a scheduled send the engine has not dispatched yet, so waMessageKey comes back null, and Swift's Decodable maps both a JSON null and an absent key to nil for an Optional. It only fills in later as an object with fromMe, remote, id, _serialized, and participant, never a bare key string.
This is the part that passes every test and breaks in production if you type it wrong. Declare it non-optional and the first scheduled send throws a decoding error on the null. Declare it WaMessageKey? and the compiler will not let you touch the inner value without if let, guard let, or optional-chaining. Force-unwrap data.waMessageKey! is the trap: it compiles, passes a test against a dispatched message, and crashes the first scheduled send that comes back null.
import Foundation
struct WaMessageKey: Decodable {
let fromMe: Bool
let remote: String?
let id: String?
let _serialized: String?
let participant: String?
}
struct MessageData: Decodable {
let id: String // 24-char hex queue id, your handle
let status: String // pending | confirmed | received | read | played | failed
let waMessageKey: WaMessageKey? // null until dispatch -> nil
}
struct Envelope: Decodable {
let success: Bool
let data: MessageData?
}
func handle(_ body: Data) throws {
let env = try JSONDecoder().decode(Envelope.self, from: body)
guard let data = env.data else { return } // a 2xx with no payload
let messageId = data.id // use this as your handle meanwhile
if let key = data.waMessageKey {
// Only inside this block does the key exist. No force-unwrap.
print("queued \(messageId), wa key: \(key._serialized ?? "n/a")")
} else {
print("queued \(messageId), not dispatched yet")
}
}
The trimmed response on a scheduled send looks like this:
{ "success": true, "data": { "id": "6a1f...c2", "status": "pending", "waMessageKey": null } }
Two Swift wins here. Decodable ignores unknown JSON keys by default, reading only the CodingKeys you declare, so a new server field never breaks decoding. And you can model status as an enum with a custom init(from:) that falls back to an .unknown case, so a new status value maps to .unknown instead of throwing. Read data.id, the 24-character hex queue id, as your handle meanwhile. The status lifecycle is pending, then confirmed, received, read, played, or failed.
How do you schedule a message for later and get sendAt right in Swift?
Add a camelCase sendAt Optional holding an RFC 3339 timestamp with an explicit offset. Build it with ISO8601DateFormatter().string(from: Date().addingTimeInterval(7200)), which defaults to GMT and writes a trailing Z, so the instant is unambiguous. Leave it nil and the message sends immediately. The accepted window is roughly 10 seconds to 365 days ahead; anything outside is rejected at validation with a 400.
import Foundation
// ISO8601DateFormatter defaults to GMT and writes 'Z'. Unambiguous instant.
let formatter = ISO8601DateFormatter()
let sendAt = formatter.string(from: Date().addingTimeInterval(2 * 3600))
// -> 2026-09-30T14:30:00Z
let body = SendBody(type: "text", text: "Reminder", sendAt: sendAt)
The Swift-specific way this goes wrong is reaching for a plain DateFormatter. Without locale = Locale(identifier: "en_US_POSIX") and timeZone = TimeZone(identifier: "UTC"), it serialises the device's local offset or a locale-shifted calendar, so the same code schedules a different instant on a phone in Berlin than in a UTC container. Prefer ISO8601DateFormatter, which is GMT by default. A second trap: compute sendAt, let the job sit in a queue for nine seconds, and dispatch lands inside the floor with 400 sendAt must be at least 10 seconds in the future. Compute it at send time, or leave a minute of headroom. Recurring cadences and deeper timezone hardening are a separate mechanism, covered in the scheduling-in-production guide.
Ready to wire this into your Vapor service or iOS app? Get a
bt_live_key and POST from the number you already own: a server-side Vapor route directly, or an iOS app through your own backend. No Meta Business verification, no message templates, no per-message fees. Point it at a live number and watch the first send land in seconds.
How do you make sends reliable from a Swift service: structured concurrency, idempotency keys, and retries that never double-send?
Push the send off the request path, retry only 429 and 5xx (never a 400), and carry a stable Idempotency-Key derived once from the business object so every retry reuses the identical value. Cap concurrency with a bounded withThrowingTaskGroup instead of spawning hundreds of unstructured Task {}s wide. That is the whole game for a swift whatsapp integration that survives real traffic.
On a server, run the send in a detached Task. On iOS, defer it to your backend: background execution is limited and the app can be suspended mid-request, so a phone is the wrong place to own a retry loop.

import Foundation
func sendReliably(apiKey: String, recipient: String, text: String,
idemKey: String) async { // idemKey is the SAME every attempt
for attempt in 1...5 {
do {
let (status, _) = try await sendOnce(apiKey: apiKey, recipient: recipient,
text: text, idemKey: idemKey)
if (200...299).contains(status) { return }
// Retry ONLY transient failures. A 400 is a bad body and fails
// identically forever, so retrying it just burns attempts.
if status == 429 || status >= 500 {
try await Task.sleep(for: .seconds(attempt)) // linear backoff
} else {
return // other 4xx: give up, retrying will not help
}
} catch {
try? await Task.sleep(for: .seconds(attempt)) // transport error
}
}
}
// Fan a batch out under structured concurrency, but bounded so 500 rows
// do not fire at once. withThrowingTaskGroup caps the in-flight count.
func sendBatch(apiKey: String, orders: [(phone: String, orderId: String)]) async {
let maxInFlight = 4
await withTaskGroup(of: Void.self) { group in
var running = 0
for order in orders {
if running >= maxInFlight { await group.next(); running -= 1 }
group.addTask {
await sendReliably(
apiKey: apiKey, recipient: order.phone,
text: "Your order \(order.orderId) has shipped.",
// Key derived ONCE from the business object. Every retry reuses it.
// A per-attempt UUID() turns each retry into a NEW message.
idemKey: "order-\(order.orderId)-shipped")
}
running += 1
}
}
}
Two rules keep the retry safe. Retry only 429, 5xx, and transport errors, never a 400, which is a bad body and fails identically forever. And compute the Idempotency-Key once from the order, ticket, or reminder (order-4471-shipped) so every attempt carries the identical value; a per-attempt UUID() turns each retry into a new message. The key is capped at 64 characters and scoped per workspace: resend the same key with an identical body and the original response replays, returning the same 24-hex message id, so detect a replay by the id, not the status. A different body under the same key returns 409 Conflict, which means your key derivation has a bug.
The bound is not only about throughput. A wide-open fan-out, five hundred unstructured Task {}s firing as fast as they drain, is the burst pattern most likely to flag a number; structured concurrency caps it naturally, and an actor-guarded permit or an AsyncSemaphore does the same job if you prefer an explicit gate. One production limit to design around: the free plan allows 5 requests per 6-hour window, shared across the REST and MCP surfaces, so a loop testing ten sends hits 429 on the sixth within a second. Any active subscription removes the ceiling. As one iOS backend operator running nightly shipping notifications put it, "the stable key was the difference between one 'your order shipped' and three at 2am after a pod restart replayed the queue."
When should you use this vs the WhatsApp Business (Cloud) API instead?
Use the own-number path for transactional volume, real two-way conversations, and a number your customers already have saved. Use Meta's Cloud API and a Business Solution Provider when you need approved marketing templates at broadcast scale, an Official Business Account badge, or a multi-agent shared inbox. The trade runs both ways.

The Swift-specific reason a hosted REST engine fits is maintenance. The maintained WhatsApp Web clients are Node projects, not Swift ones. An iOS app cannot run one at all, and a server-side Swift or Vapor service could only by supervising a second Node runtime and a browser profile next to your Swift binary forever: a session store, a reconnect supervisor, a pager rotation for something that is not your product. A hosted /v1 endpoint removes that whole tier, the argument the no-Meta-verification guide makes in full. The same endpoint answers your Vapor backend and, through it, your iOS app.
A two-way whatsapp bot swift build is the same POST plus a webhook receiver to read inbound messages, so the send half here does not change. The same first send exists in Go, Rust, Kotlin, Java, C#, and PHP, and the bot-shaped variants live in the Python and Node.js guides.
One honest warning. Spawn five hundred unstructured Task {}s without a bound and you fire them as fast as they drain, which is the pattern most likely to get a number flagged, whatever sent it; WhatsApp's rules on automated and bulk messaging apply either way. Keep concurrency low, spread a batch over hours, and remember that consent is the sender's responsibility. Nobody can guarantee a number will never be actioned.
FAQ
Can I send from Swift without a third-party package, and should I use URLSession or async-http-client?
On Apple platforms, no package is needed: Foundation ships URLSession and Codable, so build an Encodable struct, POST with try await URLSession.shared.data(for:), and decode with JSONDecoder. On a Vapor or NIO server the idiomatic client is swift-server's async-http-client, the one package a server adds; it must be shut down explicitly with try await httpClient.shutdown() or it leaks threads and file descriptors. Build one client, reuse it, and set explicit timeouts either way.
Can I call the Blueticks API directly from an iOS app, or do I need my own backend?
Route it through your own backend. A bt_live_ key embedded in a distributed iOS binary is extractable, and a leaked key sends on your number. The correct shape is an iOS app that calls your backend, which holds the key and calls Blueticks. Keep the key server-side, always.
Why does waMessageKey come back null, and how do I model it in Swift with Optional?
On a scheduled send the engine has not dispatched the message yet, so waMessageKey is null and only fills in later as an object with fromMe, remote, id, _serialized, and participant. Model it as WaMessageKey? and read it behind if let or data.waMessageKey?._serialized. Swift maps both a JSON null and an absent key to nil, so it never crashes. Use the 24-hex id as your handle meanwhile; a force-unwrap ! is the trap.
How do I stop a Swift Task retry from sending the same WhatsApp message twice?
Send an Idempotency-Key header derived once from the business object (order-4471-shipped) so every retry reuses it. A replay returns the original response and the same 24-hex message id instead of sending again. A per-attempt UUID() defeats it; the key must be stable across retries and is capped at 64 characters. Cap the fan-out with a bounded withThrowingTaskGroup or an actor permit so retries never stampede.
Do I need the Meta Cloud API to send WhatsApp from a Swift (Vapor/iOS) app?
No. The own-number path sends from a number you already control over WhatsApp Web or a hosted gateway, so there is no Meta Business verification, no message templates, and no per-message fees. Reach for the Cloud API and a Business Solution Provider only when you need approved marketing templates at broadcast scale or an Official Business Account badge.
Will sending from Swift get my WhatsApp number banned?
Nobody can guarantee it will not. You are messaging from your own number under WhatsApp's Business Policy and its rules on automated and bulk messaging, and a wide unstructured Task fan-out firing unsolicited sends is the pattern most likely to get a number flagged. Message people who asked to hear from you, cap concurrency, spread batches over hours, and stop when someone asks. Consent is yours to collect.



