Automation flows: Send webhook action
What this is: a step you can add to any Automation flow to call an external HTTP endpoint. Use it to push a contact into your CRM, kick off a job in your own backend, or notify a service that Textmagic has no built-in integration for.
The step sends one request per contact that reaches it, waits for the response, and records both in the run log. It signs every request so your endpoint can prove the call really came from Textmagic, and it can carry contact data through placeholders.
Contents
At a glance
- Methods
- GET, POST, PUT, PATCH, DELETE
- Body formats
- JSON, form, plain text
- Authentication
- Bearer, Basic, API key
- Signing
- HMAC-SHA256, Standard Webhooks
- Timeout
- 10 seconds
- Retries
- Up to 3, optional
Limits and boundaries
Worth reading before you design around the step — most of these cannot be raised.
| Limit | Value | Why |
|---|---|---|
| Webhook steps per automation | 10 | Hard limit. A warning appears from the 6th onward — each step is an external call that slows the flow down. |
| Request timeout | 10 seconds | Includes connection, TLS and the full response. A slower endpoint counts as a failure. |
| URL length | 2,048 chars | Must be absolute http or https with a real domain. |
| Body size | 65,535 chars | Measured on the template, before placeholders are substituted. |
| Query parameters | 50 | Name up to 255 chars, value up to 8,192. |
| Headers | 50 | Same size limits as query parameters. |
| Response captured | 16 KB | Stored for the run log. Longer responses are truncated; your endpoint still receives the whole request. |
| Log retention | 12 months | Request and response bodies are deleted with the run log. |
Redirects are not followed. A 301 or 302 response is treated as the final answer, not as an instruction to go elsewhere. Point the step at the final URL. This is deliberate: following a redirect would carry your credentials and signature to a host you never configured.
Some destinations are refused. You cannot send to Textmagic’s own domains, and you cannot send to private or internal network addresses — loopback, LAN ranges, link-local and cloud metadata endpoints. The address is checked after DNS resolves, so a public hostname pointing at a private IP is refused too.
Step fields
What each control in the step editor does.
The HTTP verb: GET, POST, PUT, PATCH or DELETE. A body is sent with any method that has one configured, including GET — most servers ignore it there.
The absolute endpoint address. Anything already in the query string is kept, and the parameters you add below are merged into it.
Name and value pairs appended to the URL. Values support contact placeholders and are URL-encoded for you — paste raw text, not pre-encoded strings.
Extra request headers. A handful of names are reserved — see Headers and query parameters.
Picks the format and sets Content-Type unless you override it yourself.
| Type | Content-Type sent | Escaping applied |
|---|---|---|
| None | — | No body is sent at all. |
| JSON | application/json | Substituted values are JSON-escaped, so a quote or newline in a contact field cannot break your JSON. |
| Form | application/x-www-form-urlencoded | Substituted values are URL-encoded. |
| Text | text/plain | None — values are inserted verbatim. |
The payload template. Write it as you want it to arrive, with placeholders where contact data belongs. Switching the body type back to None stops the body being sent even if text remains in the box.
None, Bearer token, Basic auth or API key. Covered in Authentication.
Generate one to have every request signed. See Webhook signing.
Retries a failed delivery on a widening interval. Client errors are never retried — see Delivery and retries.
Stop the automation ends the run for that contact. Skip this step logs the failure and carries on to the next step. Stop is the default — a failed webhook usually means the data never arrived, and continuing would build on a gap.
Send test request. Fires one real request with your current settings, without running the automation, and shows the status code and response body. The step must have a method and a URL before you can test it — the same rules that apply when you activate the automation.
Contact placeholders
Wrap a field name in single braces and it is replaced with that contact’s value at send time: {First Name}, {Last Name}, {Company Name}, {Phone}, {Email}, plus any custom field by its exact title.
| Where | Placeholders | Notes |
|---|---|---|
| Body | Yes | Escaped for the body type you picked. |
| Query parameter values | Yes | URL-encoded automatically. |
| Query parameter names | No | Sent literally. |
| URL | No | Sent literally. Put the dynamic part in a query parameter instead. |
| Headers | No | Sent literally, names and values alike. |
A JSON body carrying the standard contact fields:
{
"firstName": "{First Name}",
"lastName": "{Last Name}",
"company": "{Company Name}",
"phone": "{Phone}",
"email": "{Email}"
}An unknown placeholder is left as written. If a contact has no value for a field, the placeholder resolves to an empty string. If the field name does not exist at all, the literal text stays in the payload — a quick way to spot a typo in the run log.
Authentication
Four options. In all three authenticated modes the credential itself is encrypted at rest and never appears in the automation, the run log, or an export — only a masked hint does.
| Type | What you enter | What is sent |
|---|---|---|
| None | Nothing | No credential. |
| Bearer token | The token | Authorization: Bearer <token> |
| Basic auth | Username and password | Authorization: Basic <base64> |
| API key | Key name, key value, and where to put it | A header, or a query parameter — your choice. |
API key placement
The key name is the field the key travels in, not the key itself. With placement set to Header a key named X-Api-Key is sent as X-Api-Key: <value>; with Query a key named api_key is appended as ?api_key=<value> alongside your own parameters.
Prefer the header. A key in the query string ends up in your endpoint’s access logs, in proxy logs along the way, and in the URL shown in your Textmagic run log. Use Query only when the receiving service genuinely offers no header option.
Only the username is visible. For Basic auth the username is stored with the step in plain form so you can see which account is configured; the password is stored encrypted like every other credential.
Switching type clears the other fields. A step cannot carry a leftover username from Basic while set to Bearer. Change the type and the fields that no longer apply must be emptied before the step will save.
Headers and query parameters
Reserved header names
These are set by the sender and cannot be defined on the step:
| Header | Why it’s reserved |
|---|---|
Authorization | Use the Authentication field instead. |
Host | Derived from the URL. |
Content-Length | Computed from the body. |
Connection | Managed by the HTTP client. |
Transfer-Encoding | Managed by the HTTP client. |
webhook-* | The whole prefix belongs to signing. |
Names follow HTTP rules. Letters, digits and ! # $ % & ' * + - . ^ _ ` | ~. No spaces, no colons, no non-Latin characters. The same rule applies to query parameter names and to an API key name. The one relaxation: as a query parameter, a reserved name like authorization is fine — it collides with nothing there.
Precedence
If a header you defined collides with one the sender sets, the sender wins. Authentication is applied after your headers, and the signature after that, so a step can neither forge nor suppress them.
Webhook signing
Anyone can POST to a public URL. Signing lets your endpoint tell a genuine Textmagic request from a forged one. Generate a signing secret on the step and three headers are added to every request.
The secret is shown once. Copy it when you generate it and store it with your application’s other secrets. If you lose it, regenerate — the step keeps working, but your endpoint must be updated with the new value. Regenerating invalidates the old secret immediately.
| Header | Value |
|---|---|
webhook-id | Unique identifier for this delivery. Stable across retries — use it to deduplicate. |
webhook-timestamp | Unix seconds at the moment of signing. Changes on every attempt. |
webhook-signature | Space-separated list of versioned signatures, e.g. v1,K5p...==. Today one is sent; accept a list so rotation does not break you. |
How the signature is built
Textmagic implements the Standard Webhooks specification, so an off-the-shelf library will verify it — you do not have to write the code below if you already use one.
- The signed content is the three parts joined by dots:
{webhook-id}.{webhook-timestamp}.{raw body} - The key is the secret with its
whsec_prefix removed and the remainder base64-decoded — the key is those raw bytes, not the printable string. - The signature is HMAC-SHA256 over the signed content, base64-encoded, prefixed with
v1,.
A signed request on the wire:
POST /hooks/textmagic HTTP/1.1
host: api.example.com
content-type: application/json
webhook-id: msg_2f7a1c94e3b8
webhook-timestamp: 1757930400
webhook-signature: v1,g3sVlP0K2mQ8tRz7YxJhN1cW5bF4dE6aU9oI3pL2sK0=
{"firstName":"Bruce","email":"[email protected]"}Only the body is signed. Query parameters, headers and the URL are outside the signature. If something security-relevant travels in the query string, it is not covered — put it in the body.
Verifying a signature
Four language examples below. Pick the one you need — the logic is identical in every language.
PHP
// $body must be the raw request body, exactly as received.
function verifyTextmagicWebhook(string $secret, array $headers, string $body): bool
{
$id = $headers['webhook-id'] ?? '';
$timestamp = $headers['webhook-timestamp'] ?? '';
$header = $headers['webhook-signature'] ?? '';
if ($id === '' || $timestamp === '' || $header === '') {
return false;
}
// Reject anything older or newer than five minutes.
if (abs(time() - (int) $timestamp) > 300) {
return false;
}
$key = base64_decode(substr($secret, strlen('whsec_')), true);
if ($key === false) {
return false;
}
$expected = base64_encode(
hash_hmac('sha256', "$id.$timestamp.$body", $key, true)
);
foreach (explode(' ', $header) as $entry) {
[$version, $signature] = array_pad(explode(',', $entry, 2), 2, '');
if ($version === 'v1' && hash_equals($expected, $signature)) {
return true;
}
}
return false;
}Node.js
const crypto = require('node:crypto');
// body must be the raw request body as a string or Buffer, not a parsed object.
function verifyTextmagicWebhook(secret, headers, body) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const header = headers['webhook-signature'];
if (!id || !timestamp || !header) return false;
// Reject anything older or newer than five minutes.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = crypto
.createHmac('sha256', key)
.update(`${id}.${timestamp}.${body}`)
.digest('base64');
return header.split(' ').some((entry) => {
const [version, signature] = entry.split(',');
if (version !== 'v1' || !signature) return false;
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
}Python
import base64
import hashlib
import hmac
import time
# body must be the raw request bytes, before any JSON parsing.
def verify_textmagic_webhook(secret: str, headers: dict, body: bytes) -> bool:
message_id = headers.get("webhook-id", "")
timestamp = headers.get("webhook-timestamp", "")
header = headers.get("webhook-signature", "")
if not (message_id and timestamp and header):
return False
# Reject anything older or newer than five minutes.
if abs(time.time() - int(timestamp)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{message_id}.{timestamp}.".encode() + body
expected = base64.b64encode(
hmac.new(key, signed, hashlib.sha256).digest()
).decode()
for entry in header.split(" "):
version, _, signature = entry.partition(",")
if version == "v1" and hmac.compare_digest(expected, signature):
return True
return FalseGo
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
// body must be the raw request body, read before any decoding.
func Verify(secret string, header http.Header, body []byte) bool {
id := header.Get("webhook-id")
timestamp := header.Get("webhook-timestamp")
signatures := header.Get("webhook-signature")
if id == "" || timestamp == "" || signatures == "" {
return false
}
sent, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
// Reject anything older or newer than five minutes.
if drift := time.Now().Unix() - sent; drift > 300 || drift < -300 {
return false
}
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
return false
}
mac := hmac.New(sha256.New, key)
fmt.Fprintf(mac, "%s.%s.", id, timestamp)
mac.Write(body)
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
for _, entry := range strings.Split(signatures, " ") {
version, signature, found := strings.Cut(entry, ",")
if found && version == "v1" && hmac.Equal([]byte(expected), []byte(signature)) {
return true
}
}
return false
}Verify the raw bytes. Frameworks that parse JSON for you hand back an object; re-serialising it changes whitespace and key order, and the signature will never match. Capture the body before parsing — in Express use express.raw() on the route, in Flask use request.get_data(), in Laravel use $request->getContent().
Three checks worth making
- Compare in constant time.
hash_equals,timingSafeEqual,compare_digest,hmac.Equal— never==on the signature string. - Enforce a timestamp window. Five minutes is the usual choice. Without it, a captured request stays replayable forever.
- Deduplicate on
webhook-id. A retry carries the same id with a fresh timestamp and signature. If your handler is not idempotent, record the ids you have processed.
Delivery and retries
Any 2xx is a success. Everything else — a non-2xx status, a connection failure, a timeout — is a failure, and what happens next depends on the retry setting.
With retries on
Four attempts in total, on a widening interval:
| When | Attempt |
|---|---|
| 0s | First attempt — sent as soon as the contact reaches the step. |
| +2s | Retry 1 |
| +10s | Retry 2 |
| +42s | Retry 3 — the last one. After this the step fails and your “If the request fails” choice applies. |
A 4xx is never retried, even with retries on. A client error means the request itself is wrong — a bad token, a path that does not exist, a payload the endpoint rejects — and sending it again unchanged produces the same answer. 408 Request Timeout and 429 Too Many Requests are the exceptions: both explicitly mean “try again”, so they are retried. If you are testing retries, make your endpoint return a 5xx.
Timeouts
Each attempt is bounded at 10 seconds, covering connection, TLS handshake and the complete response. If your endpoint does real work, acknowledge the request immediately and process it in the background — that is standard practice for webhook receivers and keeps you well inside the window.
What the run log shows
Open any run of the automation and the webhook step records the method, the URL, the response status, and the outcome. Both the request body that was actually sent — placeholders already substituted — and the response body are available to download.
- Bodies are capped at 16 KB each in the log. Your endpoint still receives the complete request.
- Downloads are plain files, never rendered in the browser — a response from your endpoint is not treated as trusted content.
- Everything is removed with the run log after 12 months.
Credentials never appear. The signature and authentication headers are added after the request is recorded, so neither the log nor its downloads contain your token, password or key.
Error reference
What you will see in the run log when a step fails, and what to do about it.
| Message | Means | Fix |
|---|---|---|
| Endpoint answered with status N | Your endpoint replied with a non-2xx status. | Open the response body in the log — it usually carries the reason. |
| Request to the endpoint failed | No usable response: DNS failure, refused connection, TLS error. | Check the host resolves publicly and its certificate is valid. |
| The endpoint did not answer in time | No complete response within 10 seconds. | Acknowledge first, do the work asynchronously. |
| Cannot read the stored credential | The step points at a credential that no longer exists. | Re-enter the token, password or key on the step. |
| Cannot read the signing secret | The signing secret is gone. | Generate a new one and update your endpoint. |
| Authentication is selected but no credential is stored | An auth type is chosen with nothing saved behind it. | Enter the credential, or set authentication to None. |
Signature does not match on your side? In order of likelihood: the body was parsed and re-serialised before verification; the secret was used as a string instead of base64-decoded after whsec_; the signature was compared including the v1, prefix; or your server clock has drifted.
Security
Credentials at rest — Tokens, passwords, API keys and signing secrets are encrypted. The step stores a reference, never the value.
Shown once — A generated signing secret is displayed at creation only. Afterwards you see a masked hint.
No redirects — Redirect responses are never followed, so credentials cannot be carried to another host.
Public destinations only — Private, loopback and cloud metadata addresses are refused, checked after DNS resolution.
Reserved headers — A step cannot override the headers that carry authentication or the signature.
Transport — Use https. Plain http is accepted for internal testing, but sends your credential in the clear.
Anything you put in the URL or query string is visible. It is stored with the step, shown in the run log, and lands in your endpoint’s access logs. Keep secrets in the Authentication field, where they are encrypted, rather than embedding them in the address.