TL;DR
This tutorial shows how to build a Discord to HubSpot lead capture bot on OpenClaw, end to end, with secure signature verification, hashed identifiers, and resilient delivery. The goal is a simple slash command that collects an email and pushes a contact into HubSpot while staying inside Discord's interaction rules. You will deploy an OpenClaw playbook, verify Ed25519 signatures, map fields, and handle rate limits. The result is a repeatable foundation for workflow automation that turns community chat into qualified pipeline.
What you will build
You will create a pipeline that accepts Discord slash command interactions, validates the signature, normalizes payloads into a canonical event, and creates or updates a HubSpot contact. The workflow acknowledges quickly inside Discord, then completes the HubSpot write asynchronously and posts a follow up confirmation.
High level flow:
User /lead in Discord
→ Discord POSTs Interaction → OpenClaw HTTP trigger
→ Verify Ed25519 signature
→ Normalize and validate fields (email, name, source)
→ Enqueue contact upsert job
→ HubSpot CRM Contacts API (create or update by email)
→ Post follow up message with result
If you prefer a hosted path, you can run this inside ButterGrow to use the hosted OpenClaw assistant and production grade tooling, including queues, observability, and secrets. For a quick overview of capabilities, see the AI marketing automation features. If you are new to the product, you can get started in minutes.
Prerequisites
- A Discord application with slash commands enabled, plus its application public key and bot token.
- A Discord test server where you can install the app.
- A HubSpot account with a private app token that has CRM contacts scopes.
- An OpenClaw workspace with HTTP triggers enabled and access to a queue or background step.
- The Discord app's interactions endpoint configured to point at your OpenClaw HTTP trigger URL.
Helpful docs: Discord interactions receiving and responding, Discord rate limits, and HubSpot CRM Contacts API. These are listed in References at the end.
Architecture decisions
- Security first. Always verify Discord's Ed25519 signature against the raw request body with the X-Signature-Ed25519 and X-Signature-Timestamp headers before doing any processing.
- Short acknowledgement. Discord expects a response within about 3 seconds for interactions. Return an acknowledgement quickly and move real work to a background step.
- Idempotency. Use a deterministic key that combines Discord IDs and the normalized email hash to prevent contact duplication.
- Observability. Emit structured logs and attach correlation IDs so you can trace the journey from Discord to HubSpot.
The OpenClaw playbook
Below is a minimal OpenClaw playbook that wires the pieces together. Replace placeholder values with your own and store secrets in your workspace secret manager.
name: discord_to_hubspot_lead_capture
version: 1
triggers:
- id: discord_interaction
type: http
method: POST
path: /discord/interactions
timeout: 2500 # milliseconds to leave time for verify and ack
secrets:
DISCORD_PUBLIC_KEY: "${secrets.DISCORD_PUBLIC_KEY}"
HUBSPOT_PRIVATE_APP_TOKEN: "${secrets.HUBSPOT_PRIVATE_APP_TOKEN}"
queues:
- id: contact_jobs
max_concurrency: 5
retry:
policy: exponential
min_backoff_ms: 1000
max_backoff_ms: 30000
max_retries: 8
steps:
- id: verify_and_ack
on: discord_interaction
run: node:20
timeout: 2000
code: |
import { verify } from "tweetnacl";
import { TextEncoder } from "node:util";
export default async function handler(req, res, ctx) {
const sig = req.headers["x-signature-ed25519"]; // hex
const ts = req.headers["x-signature-timestamp"]; // string
const rawBody = await req.rawBody();
const message = new TextEncoder().encode(ts + rawBody);
const signature = Buffer.from(String(sig), "hex");
const publicKey = Buffer.from(ctx.secrets.DISCORD_PUBLIC_KEY, "hex");
const ok = verify(message, signature, publicKey);
if (!ok) {
return res.status(401).json({ error: "bad signature" });
}
const interaction = JSON.parse(rawBody);
if (interaction.type === 1) {
// PING
return res.json({ type: 1 });
}
// Extract fields from a slash command like /lead email:foo@bar.com name:"Ada"
const options = interaction?.data?.options || [];
const fields = Object.fromEntries(options.map(o => [o.name, o.value]));
const job = {
interaction_token: interaction.token,
application_id: interaction.application_id,
user_id: interaction?.member?.user?.id || interaction?.user?.id,
guild_id: interaction.guild_id,
email: String(fields.email || "").trim().toLowerCase(),
name: String(fields.name || "").trim(),
source: "discord",
};
await ctx.queues.contact_jobs.enqueue(job, {
idempotency_key: `discord:${job.guild_id}:${job.user_id}:${job.email}`,
});
// Immediate acknowledgement
return res.json({
type: 4, // CHANNEL_MESSAGE_WITH_SOURCE
data: { content: "Got it. Creating or updating your contact now." }
});
}
- id: upsert_contact
on: queue:contact_jobs
run: node:20
code: |
import crypto from "node:crypto";
import fetch from "node-fetch";
function sha256(s) {
return crypto.createHash("sha256").update(s).digest("hex");
}
async function upsertHubSpot(ctx, email, name) {
const props = {
email,
firstname: name.split(" ")[0] || undefined,
lastname: name.split(" ").slice(1).join(" ") || undefined,
source: "discord",
lifecyclestage: "subscriber",
};
const base = "https://api.hubapi.com/crm/v3/objects/contacts";
const r = await fetch(`${base}/?idProperty=email`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${ctx.secrets.HUBSPOT_PRIVATE_APP_TOKEN}`,
},
body: JSON.stringify({ properties: props }),
});
if (!r.ok) {
const body = await r.text();
throw new Error(`hubspot ${r.status}: ${body}`);
}
return r.json();
}
export default async function handler(job, ctx) {
const email = job.email;
if (!email || !email.includes("@")) {
ctx.log.warn({ job }, "missing or bad email");
return;
}
const result = await upsertHubSpot(ctx, email, job.name || "");
// Post a follow up message
const url = `https://discord.com/api/webhooks/${job.application_id}/${job.interaction_token}`;
await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: `Contact saved for ${email}.` }),
});
ctx.log.info({ email, id: result.id }, "contact upserted");
}
Step 1Create the Discord app and slash command
In the Discord Developer Portal, create an application and add a bot.
Under Interactions, set the Interactions Endpoint URL to your OpenClaw HTTP trigger path, for example
https://<your-edge>/discord/interactions.Copy the application public key and store it as
DISCORD_PUBLIC_KEYin your OpenClaw secrets.Define a slash command such as
/leadwith optionsemail(string, required) andname(string, optional). After registering, reinstall the app into your test server.
The official docs explain how to receive and respond to interactions and how rate limits work. Use those to confirm your setup and expected limits.
Step 2Configure HubSpot credentials and scopes
Create a HubSpot private app and grant at minimum crm.objects.contacts.read and crm.objects.contacts.write. Store the token as HUBSPOT_PRIVATE_APP_TOKEN in OpenClaw secrets. Review the HubSpot CRM Contacts API reference to confirm the endpoint and property names you plan to set.
Step 3Verify the Discord signature early
Discord secures interactions with Ed25519. Verification must use the exact raw body. Do not parse JSON until after signature validation. If verification fails, return 401.
TypeScript helper you can reuse inside OpenClaw steps:
import { verify } from "tweetnacl";
import { TextEncoder } from "node:util";
export function verifyDiscordSignature(rawBody: string, publicKeyHex: string, sigHex: string, timestamp: string): boolean {
const message = new TextEncoder().encode(timestamp + rawBody);
const sig = Buffer.from(sigHex, "hex");
const pub = Buffer.from(publicKeyHex, "hex");
return verify(message, sig, pub);
}
If you want to read up on Ed25519, the IETF paper on the signature scheme offers a concise overview. See the reference at the end for a link to the spec.
Step 4Normalize and validate fields
Keep a single canonical event shape so future destinations are easy to add. Normalize and trim the email, split name into first and last, and attach a source field with discord for analytics.
Example normalized event:
{
"type": "lead_submitted",
"source": "discord",
"user_id": "1234567890",
"guild_id": "99887766",
"email": "ada@example.com",
"name": "Ada Lovelace",
"timestamp": "2026-09-30T10:02:17Z"
}
Step 5Add idempotency and retries
Use a stable key such as discord:{guild_id}:{user_id}:{email} for idempotency. If a duplicate arrives within a chosen window, skip the HubSpot write or switch to an update path. Configure exponential backoff for 429 and 5xx responses.
Pseudo code:
const key = `discord:${guild_id}:${user_id}:${email}`;
if (await store.has(key)) return; // drop duplicate
try {
await createOrUpdateContact();
await store.put(key, Date.now(), { ttlSeconds: 86400 });
} catch (err) {
if (err.status === 429) throw err; // let the queue retry
// for other errors, record and retry with limits
throw err;
}
Step 6Upsert the HubSpot contact
The playbook example uses the v3 Contacts API with idProperty=email, which creates or updates in a single call. Here is a minimal curl example you can run from a terminal to validate your token and payload before wiring the step:
curl -sS -X POST "https://api.hubapi.com/crm/v3/objects/contacts/?idProperty=email" \
-H "Authorization: Bearer $HUBSPOT_PRIVATE_APP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"properties": {
"email": "ada@example.com",
"firstname": "Ada",
"lastname": "Lovelace",
"source": "discord",
"lifecyclestage": "subscriber"
}
}'
If you also want to set UTM fields or custom properties for attribution, add them under properties and ensure they exist in your HubSpot schema.
Step 7Acknowledge quickly in Discord, finish in the background
Send a short acknowledgement inside the interaction response so the user sees immediate feedback. Then continue the HubSpot write in the queue consumer step. When finished, post a follow up message to the interaction webhook URL using the application_id and interaction_token from the original payload.
Example follow up:
await fetch(`https://discord.com/api/webhooks/${application_id}/${interaction_token}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: `All set. Contact saved for ${email}.` }),
});
Step 8Monitor and trace
Add correlation IDs that persist from the HTTP trigger to the queue job and to the HubSpot response so you can trace the full journey. Configure alerts for sustained 429 responses and track the time from interaction to confirmation as a key health metric.
For more examples of server side destination wiring, compare the patterns in our LinkedIn leads to HubSpot automation guide. When you want to expand the system, the answers to common questions cover setup and trust FAQs, and the features page lists integrations you can add next.
Testing the end to end flow
Trigger the slash command
/lead email:you@example.com name:"You"in your test server.Confirm that Discord receives a fast acknowledgement in channel.
In OpenClaw, verify that a job is enqueued and processed. Check logs for the idempotency key and HubSpot response.
In HubSpot, search for the email and confirm properties were set as expected.
Trigger the same command again to validate that your idempotency layer prevents duplicates.
Simulate a rate limit by temporarily lowering concurrency, then confirm retries happen with backoff and that no events are dropped.
Common variations
- Ask for additional fields. Add optional options to the slash command such as
companyorphoneand map them to HubSpot properties. - Use modals. Discord supports opening a modal for richer inputs when a command is invoked. Handle modal submit interactions in the same endpoint.
- Add consent capture. Include a
gdpr_consentboolean and store timestamped consent strings with the contact so your list building stays compliant. - Expand destinations. After the contact is created, add a second step that sends a welcome email or grants a role in a community program.
Deployment checklist
- Secrets in place for Discord and HubSpot.
- Interactions endpoint reachable over HTTPS and returning 200 for PING.
- Slash command registered and visible in the server.
- Queue configured with reasonable concurrency and retries.
- Logs and metrics enabled for the trigger and queue step.
- Documentation for moderators on how to use the command and what success looks like.
To explore what else you can do with the platform, browse more from the ButterGrow blog, then return to get started when you are ready to ship your first automation.
Building this pipeline inside ButterGrow gives you the hosted OpenClaw assistant, built in secrets, queues, and tracing, and an onboarding flow that turns this tutorial into a deployable template. If you want to try it with your own data, use the onboarding link in the product to get started in minutes or explore what ButterGrow does first.
References
- Discord interactions receiving and responding: Endpoint shape, payloads, and response types.
- Discord rate limits guide: How per route limits and Retry-After headers work.
- HubSpot CRM Contacts API reference: Create and update contacts and properties.
- Ed25519 signature scheme (RFC 8032): Background on the signature used by Discord.
Frequently Asked Questions
How do I verify Discord request signatures in an OpenClaw HTTP trigger?+
Discord sends two headers, X-Signature-Ed25519 and X-Signature-Timestamp. Use libsodium to verify the Ed25519 signature against the raw body and timestamp with your app's public key. Reject with 401 on failure and do not parse JSON before verifying.
What is the fastest way to avoid duplicate HubSpot contacts from Discord submissions?+
Use a deterministic idempotency key such as discord:{guild_id}:{user_id}:{email_hash}. Store it in your persistence or cache layer. If the key already exists within a 24 hour window, skip creation and optionally update properties.
How do I meet Discord's 3 second response window for slash commands while still creating the contact?+
Return a 200 with an immediate acknowledgement payload and enqueue the downstream work in OpenClaw. Then post a follow up message using the interaction token when the HubSpot API call finishes. This pattern keeps the UX snappy while work continues asynchronously.
Which HubSpot API scope and object should I use for lead capture?+
For basic lead capture, use the CRM Contacts API with create or update-by-email endpoints. Ensure the private app token has crm.objects.contacts.write and crm.objects.contacts.read scopes. Map Discord fields to properties like email, firstname, and lifecycle stage.
How can I test my Discord interaction endpoint without exposing production data?+
Create a Discord test server, install the app with limited scopes, and target a staging OpenClaw environment. Use a separate HubSpot sandbox or test account and distinct tokens. Send sample slash commands and validate traces and logs before promoting to production.
What rate limits should I expect and how do I handle them?+
Discord and HubSpot enforce per route limits. Inspect 429 responses and Retry-After headers. In OpenClaw, back off with exponential delays and place requests into a priority queue. Persist failed jobs for replay so you do not lose events during spikes.
Ready to try ButterGrow?
See how ButterGrow can supercharge your growth with a quick demo.
Book a Demo