Webhooks & Notifications
TextForge can notify you whenever something happens — a new draft needs review, an email was sent, a reply came in. Set up webhooks to get real-time alerts in Slack, Discord, or any tool that accepts HTTP webhooks.
What you can be notified about
TextForge sends webhook notifications for these events:
| Event | When it fires |
|---|---|
draft.submitted |
A new draft is ready for your review |
draft.approved |
You approved a draft for sending |
draft.rejected |
You rejected a draft with feedback |
draft.expired |
A draft expired without action |
email.sent |
An approved email was successfully sent |
email.send_failed |
An email failed to send |
reply.received |
A new reply arrived in a tracked thread |
test |
You clicked "Send Test" on a webhook |
You can subscribe to all events or just the ones you care about.
Setting up a webhook
- Go to Settings → Webhooks
- Click Add Webhook
- Enter your webhook URL (where TextForge should send notifications)
- Select which events you want to receive
- Click Create
TextForge generates a signing secret for your webhook. Copy it — you'll need it to verify that notifications are genuinely from TextForge.
Slack notifications
TextForge formats Slack notifications using Block Kit for rich, interactive messages. Your Slack notifications include:
- Draft submitted — Shows the recipient, subject, and a preview of the email body. Includes buttons to view the draft directly in TextForge.
- Draft approved — Confirms the draft was approved, shows who approved it, and whether it will send immediately or at a scheduled time.
- Draft rejected — Shows the rejection reason so your team can see feedback at a glance.
- Email sent — Confirms delivery with recipient, subject, and a body preview. Includes a link to the full thread.
- Reply received — Shows who replied, the subject, and a preview. Includes buttons to view the thread or start drafting a response.
Setting up Slack
- Create a Slack Incoming Webhook for your workspace
- Copy the webhook URL (it looks like
https://hooks.slack.com/services/T.../B.../...) - Add it as a webhook in TextForge webhook settings
- Select the events you want to receive
- Send a test notification to verify everything works
Slack notifications appear with formatting, action buttons, and context — making it easy to act on drafts without leaving Slack.
Discord notifications
Discord supports Slack-compatible webhooks. To set up Discord notifications:
- In your Discord server, go to Server Settings → Integrations → Webhooks
- Click New Webhook, name it "TextForge", and select the channel
- Copy the webhook URL
- Append
/slackto the URL (e.g.,https://discord.com/api/webhooks/.../slack) - Add this URL as a webhook in TextForge
Custom webhooks
For any other tool or your own application, TextForge sends a JSON payload to your URL via HTTP POST.
Payload envelope
Every webhook delivery uses this envelope structure:
{
"id": "evt_a1b2c3d4e5f6789012345678",
"type": "draft.submitted",
"created_at": "2026-02-17T10:30:00+00:00",
"data": {
"draft_id": "01234567-89ab-cdef-0123-456789abcdef",
"subject": "Follow-up on our meeting",
"to_recipients": "[email protected]",
"body_preview": "Hi Jane, I wanted to follow up on...",
"created_by": {
"id": "user_abc123",
"name": "Aaron Stannard",
"email": "[email protected]"
},
"created_via": "API",
"creator_name": "sales-agent",
"approval_url": "https://textforge.net/api/v1/drafts/.../approve?uid=...&exp=...&sig=...",
"rejection_url": "https://textforge.net/api/v1/drafts/.../reject?uid=...&exp=...&sig=..."
},
"metadata": {
"api_version": "2026-01-01"
}
}
The id field is unique per event (format: evt_ followed by a UUIDv7 hex). The data object varies by event type. All field names use snake_case. Null values are omitted from the JSON.
HTTP headers
Every webhook delivery includes these headers:
| Header | Description | Example |
|---|---|---|
X-TextForge-Signature |
HMAC-SHA256 signature for verification | t=1708167000,v1=5a4c... |
X-TextForge-Event |
The event type | draft.submitted |
X-TextForge-Delivery |
Unique delivery ID (UUIDv7) | 019485a3-7c2e-7f1b-... |
Event data schemas
All field names use snake_case. Null and optional fields are omitted from the JSON. The data object always includes draft_id, subject, and to_recipients — each event type adds its own fields on top of these.
draft.submitted
Fired when a new draft is submitted for review. Includes one-click approval/rejection URLs.
| Field | Type | Description |
|---|---|---|
cc_recipients |
string | CC recipients, if any |
created_by |
object | {id, name, email} of the draft creator |
created_via |
string | How it was created: "UI" or "API" |
creator_name |
string | API token name or user name |
scheduled_for |
string (ISO 8601) | When the email is scheduled to send, if scheduled |
body_preview |
string | Truncated preview of the email body |
approval_url |
string | HMAC-signed URL for passwordless approval |
rejection_url |
string | HMAC-signed URL for passwordless rejection |
{
"id": "evt_019485a3-7c2e-7f1b-b3c4-d5e6f7a8b9c0",
"type": "draft.submitted",
"created_at": "2026-02-17T10:30:00+00:00",
"data": {
"draft_id": "01234567-89ab-cdef-0123-456789abcdef",
"subject": "Follow-up on our meeting",
"to_recipients": "[email protected]",
"cc_recipients": "[email protected]",
"body_preview": "Hi Jane, I wanted to follow up on the proposal we discussed...",
"created_by": {
"id": "usr_abc123",
"name": "Aaron Stannard",
"email": "[email protected]"
},
"created_via": "API",
"creator_name": "sales-agent",
"scheduled_for": "2026-02-18T09:00:00+00:00",
"approval_url": "https://textforge.net/api/v1/drafts/01234567-89ab-cdef-0123-456789abcdef/approve?uid=usr_abc123&exp=1708171800&sig=a1b2c3d4e5f6...",
"rejection_url": "https://textforge.net/api/v1/drafts/01234567-89ab-cdef-0123-456789abcdef/reject?uid=usr_abc123&exp=1708171800&sig=f6e5d4c3b2a1..."
},
"metadata": {
"api_version": "2026-01-01"
}
}
cc_recipients and scheduled_for are omitted when not set.
draft.approved
Fired when a draft is approved for sending.
| Field | Type | Description |
|---|---|---|
approved_by |
object | {id, name, email} of the approver |
approved_at |
string (ISO 8601) | When the draft was approved |
scheduled_for |
string (ISO 8601) | Original scheduled time, if any |
will_send_at |
string (ISO 8601) | When the email will actually send |
{
"id": "evt_019485a3-7c2e-7f1b-b3c4-d5e6f7a8b9c1",
"type": "draft.approved",
"created_at": "2026-02-17T11:00:00+00:00",
"data": {
"draft_id": "01234567-89ab-cdef-0123-456789abcdef",
"subject": "Follow-up on our meeting",
"to_recipients": "[email protected]",
"approved_by": {
"id": "usr_abc123",
"name": "Aaron Stannard",
"email": "[email protected]"
},
"approved_at": "2026-02-17T11:00:00+00:00",
"scheduled_for": "2026-02-18T09:00:00+00:00",
"will_send_at": "2026-02-18T09:00:00+00:00"
},
"metadata": {
"api_version": "2026-01-01"
}
}
scheduled_for and will_send_at are omitted for immediately-sent emails.
draft.rejected
Fired when a draft is rejected.
| Field | Type | Description |
|---|---|---|
rejected_by |
object | {id, name, email} of whoever rejected it |
rejected_at |
string (ISO 8601) | When the draft was rejected |
rejection_reason |
string | Reason for rejection, if provided |
{
"id": "evt_019485a3-7c2e-7f1b-b3c4-d5e6f7a8b9c2",
"type": "draft.rejected",
"created_at": "2026-02-17T11:05:00+00:00",
"data": {
"draft_id": "01234567-89ab-cdef-0123-456789abcdef",
"subject": "Follow-up on our meeting",
"to_recipients": "[email protected]",
"rejected_by": {
"id": "usr_abc123",
"name": "Aaron Stannard",
"email": "[email protected]"
},
"rejected_at": "2026-02-17T11:05:00+00:00",
"rejection_reason": "Too pushy — soften the tone in the second paragraph"
},
"metadata": {
"api_version": "2026-01-01"
}
}
rejection_reason is omitted if no reason was provided.
draft.expired
Fired when a draft expires without being approved or rejected.
| Field | Type | Description |
|---|---|---|
created_by |
object | {id, name, email} of the draft creator |
scheduled_for |
string (ISO 8601) | When it was scheduled, if applicable |
expired_at |
string (ISO 8601) | When the draft expired |
reason |
string | Why it expired |
{
"id": "evt_019485a3-7c2e-7f1b-b3c4-d5e6f7a8b9c3",
"type": "draft.expired",
"created_at": "2026-02-18T10:30:00+00:00",
"data": {
"draft_id": "01234567-89ab-cdef-0123-456789abcdef",
"subject": "Follow-up on our meeting",
"to_recipients": "[email protected]",
"created_by": {
"id": "usr_abc123",
"name": "Aaron Stannard",
"email": "[email protected]"
},
"expired_at": "2026-02-18T10:30:00+00:00",
"reason": "Draft expired after 24 hours without approval"
},
"metadata": {
"api_version": "2026-01-01"
}
}
email.sent
Fired after an approved email is delivered through the email provider.
| Field | Type | Description |
|---|---|---|
external_message_id |
string | Provider message ID (e.g., Gmail message ID) |
external_thread_id |
string | Provider thread ID for conversation tracking |
sent_at |
string (ISO 8601) | When the email was sent |
sent_by |
object | {id, name, email} of the sender |
body_preview |
string | Truncated plain text preview |
creator_name |
string | Who created the draft |
approved_by_name |
string | Who approved the draft |
{
"id": "evt_019485a3-7c2e-7f1b-b3c4-d5e6f7a8b9c4",
"type": "email.sent",
"created_at": "2026-02-18T09:00:05+00:00",
"data": {
"draft_id": "01234567-89ab-cdef-0123-456789abcdef",
"subject": "Follow-up on our meeting",
"to_recipients": "[email protected]",
"body_preview": "Hi Jane, I wanted to follow up on the proposal we discussed...",
"external_message_id": "18d3f4c2a1b09e87",
"external_thread_id": "18d3f4c2a1b09e87",
"sent_at": "2026-02-18T09:00:05+00:00",
"sent_by": {
"id": "usr_abc123",
"name": "Aaron Stannard",
"email": "[email protected]"
},
"creator_name": "sales-agent",
"approved_by_name": "Aaron Stannard"
},
"metadata": {
"api_version": "2026-01-01"
}
}
external_message_id and external_thread_id are provider-specific (e.g., Gmail message/thread IDs).
email.send_failed
Fired when an email fails to send after all retry attempts are exhausted.
| Field | Type | Description |
|---|---|---|
error |
string | Error message describing the failure |
attempt_count |
integer | Number of send attempts made |
failed_at |
string (ISO 8601) | When the failure occurred |
{
"id": "evt_019485a3-7c2e-7f1b-b3c4-d5e6f7a8b9c5",
"type": "email.send_failed",
"created_at": "2026-02-18T09:08:00+00:00",
"data": {
"draft_id": "01234567-89ab-cdef-0123-456789abcdef",
"subject": "Follow-up on our meeting",
"to_recipients": "[email protected]",
"error": "SMTP connection refused by mail server",
"attempt_count": 3,
"failed_at": "2026-02-18T09:08:00+00:00"
},
"metadata": {
"api_version": "2026-01-01"
}
}
reply.received
Fired when a reply arrives in a tracked thread.
| Field | Type | Description |
|---|---|---|
thread_id |
string (GUID) | Internal TextForge thread ID |
external_thread_id |
string | Provider thread ID |
original_draft_id |
string (GUID) | ID of the draft that started this thread |
from |
object | {email, name} of the sender |
received_at |
string (ISO 8601) | When the reply was received |
body_preview |
string | Truncated preview of the reply body |
thread_url |
string | URL to view the thread in TextForge |
{
"id": "evt_019485a3-7c2e-7f1b-b3c4-d5e6f7a8b9c6",
"type": "reply.received",
"created_at": "2026-02-19T14:22:00+00:00",
"data": {
"draft_id": "01234567-89ab-cdef-0123-456789abcdef",
"subject": "Re: Follow-up on our meeting",
"to_recipients": "[email protected]",
"thread_id": "fedcba98-7654-3210-fedc-ba9876543210",
"external_thread_id": "18d3f4c2a1b09e87",
"original_draft_id": "01234567-89ab-cdef-0123-456789abcdef",
"from": {
"email": "[email protected]",
"name": "Jane Smith"
},
"received_at": "2026-02-19T14:22:00+00:00",
"body_preview": "Thanks Aaron, that sounds great! Let's schedule a call for...",
"thread_url": "https://textforge.net/inbox/fedcba98-7654-3210-fedc-ba9876543210"
},
"metadata": {
"api_version": "2026-01-01"
}
}
original_draft_id is omitted if the reply arrived on a thread not started by a TextForge draft.
test
Fired when you click "Send Test" on a webhook. Uses placeholder values for draft_id, subject, and to_recipients.
| Field | Type | Description |
|---|---|---|
message |
string | Always "This is a test webhook delivery from TextForge" |
webhook_id |
string (GUID) | The webhook being tested |
webhook_name |
string | Display name of the webhook |
{
"id": "evt_019485a3-7c2e-7f1b-b3c4-d5e6f7a8b9c7",
"type": "test",
"created_at": "2026-02-17T10:00:00+00:00",
"data": {
"draft_id": "00000000-0000-0000-0000-000000000000",
"subject": "",
"to_recipients": "",
"message": "This is a test webhook delivery from TextForge",
"webhook_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"webhook_name": "My Webhook"
},
"metadata": {
"api_version": "2026-01-01"
}
}
Verifying webhook signatures
Every webhook delivery includes a signature header so you can verify it came from TextForge and wasn't tampered with.
Your webhook signing secret starts with whsec_ and is shown once when you create the webhook. Store it securely.
Webhooks created before the signing update display a legacy notice in TextForge. They continue delivering, but the displayed secret cannot verify their existing signatures. Regenerate the secret from the Webhooks page before enabling signature verification in your receiver.
Signature format
The X-TextForge-Signature header has this format:
t=1708167000,v1=5a4c3b2a1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4
tis the Unix timestamp (seconds) when the signature was generatedv1is the hex-encoded HMAC-SHA256 signature
How to verify
- Parse the
tandv1values from theX-TextForge-Signatureheader - Construct the signed payload:
{timestamp}.{raw_json_body}(the timestamp, a period, then the exact request body received, without parsing or reformatting it) - Compute HMAC-SHA256 of the signed payload using your webhook secret
- Compare your computed signature with the
v1value
Python example
import hmac
import hashlib
def verify_signature(payload_body: bytes, signature_header: str, secret: str) -> bool:
# Parse "t=...,v1=..." from header
parts = dict(p.split("=", 1) for p in signature_header.split(","))
timestamp = parts["t"]
expected_sig = parts["v1"]
# Build the signed payload: "{timestamp}.{body}"
signed_payload = f"{timestamp}.{payload_body.decode('utf-8')}"
# Compute HMAC-SHA256
computed = hmac.new(
secret.encode("utf-8"),
signed_payload.encode("utf-8"),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(computed, expected_sig)
Node.js example
const crypto = require("crypto");
function verifySignature(rawBody, signatureHeader, secret) {
// Parse "t=...,v1=..." from header
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.split("=", 2))
);
const timestamp = parts.t;
const expectedSig = parts.v1;
// Build the signed payload: "{timestamp}.{body}"
const signedPayload = `${timestamp}.${rawBody}`;
// Compute HMAC-SHA256
const computed = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(computed, "hex"),
Buffer.from(expectedSig, "hex")
);
}
Replay protection
To protect against replay attacks, check the t timestamp. Reject deliveries where the timestamp is more than 5 minutes old:
import time
def is_timestamp_valid(signature_header: str, tolerance_seconds: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
timestamp = int(parts["t"])
return abs(time.time() - timestamp) < tolerance_seconds
Retry behavior
When a delivery fails, TextForge retries up to 3 times with increasing delays:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
TextForge retries on these failures:
- HTTP 429 (rate limited)
- HTTP 5xx (server errors)
- Connection errors and timeouts
TextForge does not retry on HTTP 4xx errors (other than 429). A 400 or 403 usually means a configuration problem on your end, so retrying won't help.
Each request has a 10-second timeout. If your endpoint doesn't respond within 10 seconds, it counts as a failure.
Auto-disable on repeated failures
After 10 consecutive failures (across any combination of events), TextForge automatically disables the webhook to stop hammering a broken endpoint. You'll see the failure count and disabled status on the webhooks settings page. Re-enable it after fixing the issue.
Testing your webhook
After creating a webhook, use the Send Test button to send a test notification. This sends a test event to your endpoint so you can verify:
- Your URL is reachable
- Your endpoint handles the payload correctly
- Slack/Discord formatting looks right
- Signature verification works