Click actions
An action is a named button or link. Mark Confirm order with confirm_order and every click on it reaches your webhook as email.clicked with click.action: "confirm_order" and the email’s metadata, such as the order id. No parsing URLs to tell one button from another.
Mark a link
In HTML, whether a code template or html in a send, add data-zap-action to the link. In the visual editor, every Button block and every link in text has an Action field: pick an action the account already has, or type a new name.
<p>Thanks for order {{order_id}}. Please confirm it:</p>
<a href="https://acme.com/orders/{{order_id}}/confirm" data-zap-action="confirm_order">Confirm order</a>
<a href="https://acme.com/orders/{{order_id}}/track" data-zap-action="track_parcel">Track parcel</a>{
"from": "Acme <orders@acme.com>",
"to": [
"ann@example.com"
],
"template": {
"id": "receipt",
"variables": {
"order_id": "A-1042"
}
},
"metadata": {
"order_id": "A-1042"
}
}| Names | Lowercase letters, digits, _, . and -, starting with a letter or digit, up to 64 characters: confirm_order, plan.upgrade. A link whose name does not fit is treated as an ordinary link, without an action. |
|---|---|
| Which links | An <a> whose href is an absolute http:// or https:// URL, in the HTML part. The attribute may stand anywhere in the tag, quoted with " or '. |
| Click tracking off | A link with an action is tracked anyway, whether click tracking is off for the domain or for the send (tracking.clicks: false). Other links follow the usual setting. |
data-zap-notrack | Wins: a link with both attributes is left alone, so no click is reported. |
| Forgery | The action and the destination are signed into the link, so a recipient cannot change the action or point it elsewhere. The browser is redirected to your href. |
| Declaring | Not needed. Any valid name works the moment an email carries it, and it appears in the catalogue as not described. |
Everything else about click tracking applies: links work for 365 days after the send, a repeated click on the same link from the same network within 10 minutes still redirects but is not reported again, and links in the plain-text part are not rewritten.
What the webhook receives
A click on a marked link is an ordinary email.clicked event, with two more fields in click:
link | The destination, as the browser followed it. |
|---|---|
action | The link’s action. Absent for links without one. |
unique | Present on every click: true on the first click of that action by that recipient of that email, false on later clicks and on links without an action. Use it for “confirm once” flows. |
ip_address, user_agent | Of the click. |
The event, like email.opened, carries the email’s metadata and tags when each fits in 200 bytes (metadata as JSON, tags URL-encoded), so keep the ids you need there short: {"order_id": "A-1042"}. For click events, from holds only the sending domain and there is no subject. When an email has several recipients, its links belong to the first to address, so send one recipient per email when the action is a person’s answer.
{
"id": "evt_7a1d0c3e9b2f4a6c8e5d1b07",
"type": "email.clicked",
"created_at": "2026-10-27T08:41:05.214Z",
"data": {
"email_id": "019a1f2e-7c3b-7d41-9a52-0c4b8e6f1a23",
"from": "<@acme.com>",
"to": "ann@example.com",
"tags": {
"category": "receipt"
},
"metadata": {
"order_id": "A-1042"
},
"stream": "transactional",
"click": {
"link": "https://acme.com/orders/A-1042/confirm",
"action": "confirm_order",
"unique": true,
"ip_address": "203.0.113.24",
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"
}
}
}unique is a convenience, not a lock. In rare cases, such as a failure of our internal cache, a repeat click may also arrive with unique: true. Make the work behind it idempotent on your side: confirming an order twice should change nothing.
// after verifying Zap-Signature (see Webhook events)
const event = JSON.parse(req.body);
if (event.type === 'email.clicked' && event.data.click?.action === 'confirm_order') {
const orderId = event.data.metadata?.order_id;
if (event.data.click.unique) {
await orders.confirm(orderId); // make this idempotent too
}
}
res.sendStatus(200);event = request.get_json()
click = event["data"].get("click") or {}
if event["type"] == "email.clicked" and click.get("action") == "confirm_order":
order_id = (event["data"].get("metadata") or {}).get("order_id")
if click.get("unique"):
orders.confirm(order_id) # make this idempotent too
return "", 200Send actions to their own endpoints
A webhook endpoint takes an optional filter. With filter.actions set, it receives email.clicked only for links with one of those actions; every other event type it subscribes to is unaffected. Without a filter, or with an empty list, it receives every click, as before. Use one endpoint per action, or one for several: up to 50 actions per endpoint.
Send one button to its own endpoint
POST/v1/webhooks
This endpoint receives email.clicked only for links marked confirm_order. Reference: Create a webhook.
Requires a full-access live key; test keys get 403 test_key_restricted.
curl -X POST 'https://zapsmtp.xyz/v1/webhooks' \
-H "Authorization: Bearer $ZAP_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"url": "https://acme.com/hooks/confirm-order",
"events": [
"email.clicked"
],
"filter": {
"actions": [
"confirm_order"
]
}
}'const res = await fetch('https://zapsmtp.xyz/v1/webhooks', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZAP_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"url": "https://acme.com/hooks/confirm-order",
"events": [
"email.clicked"
],
"filter": {
"actions": [
"confirm_order"
]
}
}),
});
// 204 No Content (e.g. a delete) has no body
const data = res.status === 204 ? null : await res.json();
if (!res.ok) throw new Error(`${data.name}: ${data.message}`);
console.log(data);import os
import requests
res = requests.post(
"https://zapsmtp.xyz/v1/webhooks",
headers={"Authorization": f"Bearer {os.environ['ZAP_API_KEY']}"},
json={
"url": "https://acme.com/hooks/confirm-order",
"events": [
"email.clicked",
],
"filter": {
"actions": [
"confirm_order",
],
},
},
timeout=30,
)
res.raise_for_status()
print(res.json() if res.status_code != 204 else "deleted")<?php
$ch = curl_init('https://zapsmtp.xyz/v1/webhooks');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('ZAP_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'url' => 'https://acme.com/hooks/confirm-order',
'events' => [
'email.clicked',
],
'filter' => [
'actions' => [
'confirm_order',
],
],
]),
]);
$data = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
throw new RuntimeException($data['message']);
}
print_r($data);package main
import (
"fmt"
"io"
"net/http"
"os"
"strings"
)
func main() {
body := strings.NewReader(`{
"url": "https://acme.com/hooks/confirm-order",
"events": [
"email.clicked"
],
"filter": {
"actions": [
"confirm_order"
]
}
}`)
req, err := http.NewRequest(
"POST",
"https://zapsmtp.xyz/v1/webhooks",
body,
)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("ZAP_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(out))
}require 'net/http'
require 'json'
uri = URI('https://zapsmtp.xyz/v1/webhooks')
req = Net::HTTP::Post.new(uri)
req['Authorization'] = "Bearer #{ENV.fetch('ZAP_API_KEY')}"
req['Content-Type'] = 'application/json'
req.body = {
url: "https://acme.com/hooks/confirm-order",
events: [
"email.clicked"
],
filter: {
actions: [
"confirm_order"
]
}
}.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
puts res.code, (JSON.parse(res.body) unless res.body.to_s.empty?)import java.net.URI;
import java.net.http.*;
public class ZapRequest {
public static void main(String[] args) throws Exception {
var request = HttpRequest.newBuilder(URI.create("https://zapsmtp.xyz/v1/webhooks"))
.header("Authorization", "Bearer " + System.getenv("ZAP_API_KEY"))
.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{
"url": "https://acme.com/hooks/confirm-order",
"events": [
"email.clicked"
],
"filter": {
"actions": [
"confirm_order"
]
}
}"""))
.build();
var response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode() + " " + response.body());
}
}using System.Net.Http.Headers;
using System.Text;
using var http = new HttpClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://zapsmtp.xyz/v1/webhooks");
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("ZAP_API_KEY"));
request.Content = new StringContent("""
{
"url": "https://acme.com/hooks/confirm-order",
"events": [
"email.clicked"
],
"filter": {
"actions": [
"confirm_order"
]
}
}
""", Encoding.UTF8, "application/json");
var response = await http.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");ZAP_API_KEY in your environment.Response 201 Created
{
"id": "019a1c55-0d6e-7f21-8b3a-5e9d4c7b2a10",
"url": "https://acme.com/hooks/confirm-order",
"secret": "whsec_Q7v2Lk9Xp4Rb1Tz8Mw3Nc6Hy0Fd5Gs2J",
"events": [
"email.clicked"
],
"enabled": true,
"include_content": false,
"batch": false,
"filter": {
"actions": [
"confirm_order"
]
},
"disabled_reason": null,
"created_at": "2026-10-27T08:00:00Z"
}Add an action to an endpoint
PATCH/v1/webhooks/{id}
filter is replaced as a whole: send the full list. {"actions": []} removes the filter, and the endpoint receives every click again. Reference: Update a webhook.
Requires a full-access live key; test keys get 403 test_key_restricted.
Path parameters
idstringrequired- Webhook id
curl -X PATCH 'https://zapsmtp.xyz/v1/webhooks/019a1c55-0d6e-7f21-8b3a-5e9d4c7b2a10' \
-H "Authorization: Bearer $ZAP_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"filter": {
"actions": [
"confirm_order",
"cancel_order"
]
}
}'const res = await fetch('https://zapsmtp.xyz/v1/webhooks/019a1c55-0d6e-7f21-8b3a-5e9d4c7b2a10', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.ZAP_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"filter": {
"actions": [
"confirm_order",
"cancel_order"
]
}
}),
});
// 204 No Content (e.g. a delete) has no body
const data = res.status === 204 ? null : await res.json();
if (!res.ok) throw new Error(`${data.name}: ${data.message}`);
console.log(data);import os
import requests
res = requests.patch(
"https://zapsmtp.xyz/v1/webhooks/019a1c55-0d6e-7f21-8b3a-5e9d4c7b2a10",
headers={"Authorization": f"Bearer {os.environ['ZAP_API_KEY']}"},
json={
"filter": {
"actions": [
"confirm_order",
"cancel_order",
],
},
},
timeout=30,
)
res.raise_for_status()
print(res.json() if res.status_code != 204 else "deleted")<?php
$ch = curl_init('https://zapsmtp.xyz/v1/webhooks/019a1c55-0d6e-7f21-8b3a-5e9d4c7b2a10');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'PATCH',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('ZAP_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'filter' => [
'actions' => [
'confirm_order',
'cancel_order',
],
],
]),
]);
$data = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
throw new RuntimeException($data['message']);
}
print_r($data);package main
import (
"fmt"
"io"
"net/http"
"os"
"strings"
)
func main() {
body := strings.NewReader(`{
"filter": {
"actions": [
"confirm_order",
"cancel_order"
]
}
}`)
req, err := http.NewRequest(
"PATCH",
"https://zapsmtp.xyz/v1/webhooks/019a1c55-0d6e-7f21-8b3a-5e9d4c7b2a10",
body,
)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("ZAP_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(out))
}require 'net/http'
require 'json'
uri = URI('https://zapsmtp.xyz/v1/webhooks/019a1c55-0d6e-7f21-8b3a-5e9d4c7b2a10')
req = Net::HTTP::Patch.new(uri)
req['Authorization'] = "Bearer #{ENV.fetch('ZAP_API_KEY')}"
req['Content-Type'] = 'application/json'
req.body = {
filter: {
actions: [
"confirm_order",
"cancel_order"
]
}
}.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
puts res.code, (JSON.parse(res.body) unless res.body.to_s.empty?)import java.net.URI;
import java.net.http.*;
public class ZapRequest {
public static void main(String[] args) throws Exception {
var request = HttpRequest.newBuilder(URI.create("https://zapsmtp.xyz/v1/webhooks/019a1c55-0d6e-7f21-8b3a-5e9d4c7b2a10"))
.header("Authorization", "Bearer " + System.getenv("ZAP_API_KEY"))
.header("Content-Type", "application/json")
.method("PATCH", HttpRequest.BodyPublishers.ofString("""
{
"filter": {
"actions": [
"confirm_order",
"cancel_order"
]
}
}"""))
.build();
var response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode() + " " + response.body());
}
}using System.Net.Http.Headers;
using System.Text;
using var http = new HttpClient();
var request = new HttpRequestMessage(HttpMethod.Patch, "https://zapsmtp.xyz/v1/webhooks/019a1c55-0d6e-7f21-8b3a-5e9d4c7b2a10");
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("ZAP_API_KEY"));
request.Content = new StringContent("""
{
"filter": {
"actions": [
"confirm_order",
"cancel_order"
]
}
}
""", Encoding.UTF8, "application/json");
var response = await http.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");ZAP_API_KEY in your environment.Response 200 OK
{
"id": "019a1c55-0d6e-7f21-8b3a-5e9d4c7b2a10",
"url": "https://acme.com/hooks/confirm-order",
"events": [
"email.clicked"
],
"enabled": true,
"include_content": false,
"batch": false,
"filter": {
"actions": [
"confirm_order",
"cancel_order"
]
},
"disabled_reason": null,
"created_at": "2026-10-27T08:00:00Z"
}The catalogue
The Actions page in the dashboard and GET /actions show each action with its description, clicks over 7 and 30 days, unique clickers over 30 days, the last click, how many templates use it and the webhook endpoints filtered on it. declared: false marks an action that links use but nobody described. In the dashboard, New action can also create a webhook for it, or add it to an existing endpoint’s filter, in the same step.
Deleting an action removes only its description. Links that name it keep working, and webhooks filtered on it keep receiving its clicks.
Read the catalogue
GET/v1/actions
Every action the account declared, every action links named in the last 30 days, and every action a template or webhook filter uses, with activity. Reference: List actions.
Requires a full-access key.
curl 'https://zapsmtp.xyz/v1/actions' \
-H "Authorization: Bearer $ZAP_API_KEY"const res = await fetch('https://zapsmtp.xyz/v1/actions', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.ZAP_API_KEY}`,
},
});
// 204 No Content (e.g. a delete) has no body
const data = res.status === 204 ? null : await res.json();
if (!res.ok) throw new Error(`${data.name}: ${data.message}`);
console.log(data);import os
import requests
res = requests.get(
"https://zapsmtp.xyz/v1/actions",
headers={"Authorization": f"Bearer {os.environ['ZAP_API_KEY']}"},
timeout=30,
)
res.raise_for_status()
print(res.json() if res.status_code != 204 else "deleted")<?php
$ch = curl_init('https://zapsmtp.xyz/v1/actions');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('ZAP_API_KEY'),
],
]);
$data = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
throw new RuntimeException($data['message']);
}
print_r($data);package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest(
"GET",
"https://zapsmtp.xyz/v1/actions",
nil,
)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("ZAP_API_KEY"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(out))
}require 'net/http'
require 'json'
uri = URI('https://zapsmtp.xyz/v1/actions')
req = Net::HTTP::Get.new(uri)
req['Authorization'] = "Bearer #{ENV.fetch('ZAP_API_KEY')}"
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
puts res.code, (JSON.parse(res.body) unless res.body.to_s.empty?)import java.net.URI;
import java.net.http.*;
public class ZapRequest {
public static void main(String[] args) throws Exception {
var request = HttpRequest.newBuilder(URI.create("https://zapsmtp.xyz/v1/actions"))
.header("Authorization", "Bearer " + System.getenv("ZAP_API_KEY"))
.method("GET", HttpRequest.BodyPublishers.noBody())
.build();
var response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode() + " " + response.body());
}
}using System.Net.Http.Headers;
using System.Text;
using var http = new HttpClient();
var request = new HttpRequestMessage(HttpMethod.Get, "https://zapsmtp.xyz/v1/actions");
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("ZAP_API_KEY"));
var response = await http.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");ZAP_API_KEY in your environment.Response 200 OK
{
"object": "list",
"has_more": false,
"data": [
{
"object": "action",
"name": "confirm_order",
"description": "Customer confirms the order from the receipt",
"declared": true,
"created_at": "2026-10-24T09:12:00Z",
"updated_at": "2026-10-24T09:12:00Z",
"clicks_7d": 42,
"clicks_30d": 180,
"unique_clickers_30d": 171,
"last_click_at": "2026-10-27T08:41:05Z",
"templates": 1,
"webhooks": [
"019a1c55-0d6e-7f21-8b3a-5e9d4c7b2a10"
]
},
{
"object": "action",
"name": "track_parcel",
"description": "",
"declared": false,
"created_at": null,
"updated_at": null,
"clicks_7d": 12,
"clicks_30d": 64,
"unique_clickers_30d": 60,
"last_click_at": "2026-10-26T19:02:44Z",
"templates": 2,
"webhooks": []
}
]
}Describe an action
POST/v1/actions
Optional: a link works with any valid name. Declaring one gives it a description your team sees in the dashboard. PATCH /actions/{name} changes it later, and declares an action that links already use. Reference: Declare an action.
Requires a full-access live key; test keys get 403 test_key_restricted.
curl -X POST 'https://zapsmtp.xyz/v1/actions' \
-H "Authorization: Bearer $ZAP_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"name": "confirm_order",
"description": "Customer confirms the order from the receipt"
}'const res = await fetch('https://zapsmtp.xyz/v1/actions', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZAP_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"name": "confirm_order",
"description": "Customer confirms the order from the receipt"
}),
});
// 204 No Content (e.g. a delete) has no body
const data = res.status === 204 ? null : await res.json();
if (!res.ok) throw new Error(`${data.name}: ${data.message}`);
console.log(data);import os
import requests
res = requests.post(
"https://zapsmtp.xyz/v1/actions",
headers={"Authorization": f"Bearer {os.environ['ZAP_API_KEY']}"},
json={
"name": "confirm_order",
"description": "Customer confirms the order from the receipt",
},
timeout=30,
)
res.raise_for_status()
print(res.json() if res.status_code != 204 else "deleted")<?php
$ch = curl_init('https://zapsmtp.xyz/v1/actions');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('ZAP_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'name' => 'confirm_order',
'description' => 'Customer confirms the order from the receipt',
]),
]);
$data = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
throw new RuntimeException($data['message']);
}
print_r($data);package main
import (
"fmt"
"io"
"net/http"
"os"
"strings"
)
func main() {
body := strings.NewReader(`{
"name": "confirm_order",
"description": "Customer confirms the order from the receipt"
}`)
req, err := http.NewRequest(
"POST",
"https://zapsmtp.xyz/v1/actions",
body,
)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("ZAP_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(out))
}require 'net/http'
require 'json'
uri = URI('https://zapsmtp.xyz/v1/actions')
req = Net::HTTP::Post.new(uri)
req['Authorization'] = "Bearer #{ENV.fetch('ZAP_API_KEY')}"
req['Content-Type'] = 'application/json'
req.body = {
name: "confirm_order",
description: "Customer confirms the order from the receipt"
}.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
puts res.code, (JSON.parse(res.body) unless res.body.to_s.empty?)import java.net.URI;
import java.net.http.*;
public class ZapRequest {
public static void main(String[] args) throws Exception {
var request = HttpRequest.newBuilder(URI.create("https://zapsmtp.xyz/v1/actions"))
.header("Authorization", "Bearer " + System.getenv("ZAP_API_KEY"))
.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{
"name": "confirm_order",
"description": "Customer confirms the order from the receipt"
}"""))
.build();
var response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode() + " " + response.body());
}
}using System.Net.Http.Headers;
using System.Text;
using var http = new HttpClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://zapsmtp.xyz/v1/actions");
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("ZAP_API_KEY"));
request.Content = new StringContent("""
{
"name": "confirm_order",
"description": "Customer confirms the order from the receipt"
}
""", Encoding.UTF8, "application/json");
var response = await http.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");ZAP_API_KEY in your environment.Response 201 Created
{
"object": "action",
"name": "confirm_order",
"description": "Customer confirms the order from the receipt",
"declared": true,
"created_at": "2026-10-24T09:12:00Z",
"updated_at": "2026-10-24T09:12:00Z",
"clicks_7d": 0,
"clicks_30d": 0,
"unique_clickers_30d": 0,
"last_click_at": null,
"templates": 0,
"webhooks": []
}