Docs

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

  1. Go to Settings → Webhooks
  2. Click Add Webhook
  3. Enter your webhook URL (where TextForge should send notifications)
  4. Select which events you want to receive
  5. Click Create

TextForge generates a signing secret for your webhook. Copy it — you'll need it to verify that notifications are genuinely from TextForge.

Webhooks settings page showing the Add Webhook form with URL field, event checkboxes, and the generated signing secret.
The webhook configuration page where you set up notification endpoints.

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

  1. Create a Slack Incoming Webhook for your workspace
  2. Copy the webhook URL (it looks like https://hooks.slack.com/services/T.../B.../...)
  3. Add it as a webhook in TextForge webhook settings
  4. Select the events you want to receive
  5. 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:

  1. In your Discord server, go to Server Settings → Integrations → Webhooks
  2. Click New Webhook, name it "TextForge", and select the channel
  3. Copy the webhook URL
  4. Append /slack to the URL (e.g., https://discord.com/api/webhooks/.../slack)
  5. 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
  • t is the Unix timestamp (seconds) when the signature was generated
  • v1 is the hex-encoded HMAC-SHA256 signature

How to verify

  1. Parse the t and v1 values from the X-TextForge-Signature header
  2. Construct the signed payload: {timestamp}.{raw_json_body} (the timestamp, a period, then the exact request body received, without parsing or reformatting it)
  3. Compute HMAC-SHA256 of the signed payload using your webhook secret
  4. Compare your computed signature with the v1 value

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