Textmagic help center

Get the help you need with our comprehensive business texting support center

Automation flows: Send webhook action

vurl:

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.

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.

LimitValueWhy
Webhook steps per automation10Hard limit. A warning appears from the 6th onward — each step is an external call that slows the flow down.
Request timeout10 secondsIncludes connection, TLS and the full response. A slower endpoint counts as a failure.
URL length2,048 charsMust be absolute http or https with a real domain.
Body size65,535 charsMeasured on the template, before placeholders are substituted.
Query parameters50Name up to 255 chars, value up to 8,192.
Headers50Same size limits as query parameters.
Response captured16 KBStored for the run log. Longer responses are truncated; your endpoint still receives the whole request.
Log retention12 monthsRequest 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.

MethodRequired to activate

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.

URLRequired to activate

The absolute endpoint address. Anything already in the query string is kept, and the parameters you add below are merged into it.

Query parametersOptional

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.

HeadersOptional

Extra request headers. A handful of names are reserved — see Headers and query parameters.

Body typeOptional

Picks the format and sets Content-Type unless you override it yourself.

TypeContent-Type sentEscaping applied
None—No body is sent at all.
JSONapplication/jsonSubstituted values are JSON-escaped, so a quote or newline in a contact field cannot break your JSON.
Formapplication/x-www-form-urlencodedSubstituted values are URL-encoded.
Texttext/plainNone — values are inserted verbatim.
BodyOptional

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.

AuthenticationOptional

None, Bearer token, Basic auth or API key. Covered in Authentication.

Signing secretOptionalRecommended

Generate one to have every request signed. See Webhook signing.

Retry up to 3 timesOptional

Retries a failed delivery on a widening interval. Client errors are never retried — see Delivery and retries.

If the request failsOptional

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.

WherePlaceholdersNotes
BodyYesEscaped for the body type you picked.
Query parameter valuesYesURL-encoded automatically.
Query parameter namesNoSent literally.
URLNoSent literally. Put the dynamic part in a query parameter instead.
HeadersNoSent 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.

TypeWhat you enterWhat is sent
NoneNothingNo credential.
Bearer tokenThe tokenAuthorization: Bearer <token>
Basic authUsername and passwordAuthorization: Basic <base64>
API keyKey name, key value, and where to put itA 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:

HeaderWhy it’s reserved
AuthorizationUse the Authentication field instead.
HostDerived from the URL.
Content-LengthComputed from the body.
ConnectionManaged by the HTTP client.
Transfer-EncodingManaged 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.

HeaderValue
webhook-idUnique identifier for this delivery. Stable across retries — use it to deduplicate.
webhook-timestampUnix seconds at the moment of signing. Changes on every attempt.
webhook-signatureSpace-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 False

Go

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:

WhenAttempt
0sFirst attempt — sent as soon as the contact reaches the step.
+2sRetry 1
+10sRetry 2
+42sRetry 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.

MessageMeansFix
Endpoint answered with status NYour endpoint replied with a non-2xx status.Open the response body in the log — it usually carries the reason.
Request to the endpoint failedNo usable response: DNS failure, refused connection, TLS error.Check the host resolves publicly and its certificate is valid.
The endpoint did not answer in timeNo complete response within 10 seconds.Acknowledge first, do the work asynchronously.
Cannot read the stored credentialThe step points at a credential that no longer exists.Re-enter the token, password or key on the step.
Cannot read the signing secretThe signing secret is gone.Generate a new one and update your endpoint.
Authentication is selected but no credential is storedAn 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.

Try our fully featured business
texting platform today

Grow revenue and improve engagement rates by sending personalized, action-driven texts to your customers, staff, and suppliers.