Poolday integration guide
This guide covers how to connect Poolday to an existing system. Your system triggers a run by POSTing an event to a webhook automation. When the agent has finished, it hands the result back by calling a tool on an MCP server you host. There is no polling and no job id. The tool call is the return path.
Everything on the Poolday side is configured in the interface: Automations for the inbound side, Connectors for the outbound side. The only code you write runs on your side.
1. How it fits together
YOUR SYSTEM POOLDAY
----------- -------
something happens --POST JSON--> Automation (webhook)
(order created, your prompt runs with the event data
brief approved) |
v
The agent makes the video
|
your MCP server <--tool call--------------'
(deliver_video, the agent calls a tool you host
update_status) to hand the result backThe webhook is one-way. Poolday answers 202 Accepted and starts working. It does not return a job id or a result, and it does not call you back on its own.
Results come back through tools you host. You run a small MCP server with tools such as deliver_video. The automation’s prompt tells the agent to call them when it is done.
One user owns both ends. The automation and the MCP connection belong to one Poolday user. Create a dedicated user in your organization for the integration, for example integrations@yourco.com, and do everything below signed in as that user.
2. Inbound: the webhook automation
A webhook automation is a stored prompt that runs every time you POST to its URL.
2.1 Create the automation
Organization > Automations > New > Source: Webhook
| Field | Value |
|---|---|
| Name | Anything descriptive, for example Order to product video |
| Prompt | What the agent should do. Use {{event.…}} to insert fields from the JSON you send. See 2.4. |
| Mode / tier | Whatever you would pick in the composer for this kind of job |
| Ask questions | Off. Nobody is watching the conversation, so the prompt has to say what to do when the agent is blocked. |
| Target | New conversation per event for independent jobs. One conversation per key (for example key id) if you will send follow-up events about the same entity and want them in the same chat. |
| Sign requests | On |
| Idempotency header | Idempotency-Key, or your own header name such as X-Event-Id |
| Attach files | On if your events reference images, videos or documents |
Save. Poolday shows the webhook URL and secret once. Store both in your secret manager. The Rotate secret button issues a new pair, and the old URL stops working immediately.
2.2 Request format
POST <the URL Poolday gave you>
Content-Type: application/json
X-Poolday-Signature: sha256=<hex HMAC-SHA256 of the body, keyed with the secret>
Idempotency-Key: <your unique event id>
{ ...any JSON... }| Part | Rule |
|---|---|
| Body | Any JSON. There is no required shape. Everything you send is available to the prompt. |
| Signature | HMAC-SHA256 of the exact bytes of the body, keyed with the secret, formatted as sha256=<hex>. Serialize once and send that same string. |
| Idempotency-Key | Your delivery id. A retry with the same key never runs the job twice. Without one, an identical body within 5 minutes also counts as a duplicate. |
| Files | Add "files": [{ "url": "https://…", "name": "hero.jpg" }]. Poolday downloads them (public https only) and hands them to the agent. Up to 10 files, 50 MB each. Alternatively, POST multipart/form-data with file parts. |
Responses
| Status | Meaning |
|---|---|
| 202 | { "accepted": 1 }: received, the job is starting. { "accepted": 0, "deduplicated": 1 }: you already sent this one. |
| 200 | { "ignored": true }: the automation is disabled or paused. Nothing runs. |
| 401 | Wrong URL token or bad signature. |
| 404 | Unknown automation. The URL is wrong or was rotated. |
| 5xx | Retry with backoff. The idempotency key makes this safe. |
2.3 Sender code
// Node / TypeScript import { createHmac, randomUUID } from 'node:crypto'; const HOOK_URL = process.env.POOLDAY_HOOK_URL!; // from the one-time reveal const HOOK_SECRET = process.env.POOLDAY_HOOK_SECRET!; export async function sendToPoolday(event: object, eventId = randomUUID()) { const body = JSON.stringify(event); // sign exactly these bytes const signature = 'sha256=' + createHmac('sha256', HOOK_SECRET).update(body).digest('hex'); for (let attempt = 1; ; attempt++) { const res = await fetch(HOOK_URL, { method: 'POST', headers: { 'content-type': 'application/json', 'x-poolday-signature': signature, 'idempotency-key': eventId }, body, }); if (res.status === 202 || res.status === 200) return res.json(); if (res.status < 500) throw new Error(`Poolday rejected the webhook (${res.status}): ${await res.text()}`); if (attempt >= 4) throw new Error(`Poolday webhook failed (${res.status})`); await new Promise((r) => setTimeout(r, 500 * 2 ** attempt)); } } // usage await sendToPoolday({ event: 'order.created', id: 'ord_84213', customer: { name: 'Jane Doe' }, product: { title: 'Trail Runner 2', sku: 'TR2-BLK-42' }, files: [{ url: 'https://cdn.yourco.com/products/tr2/hero.jpg', name: 'hero.jpg' }, { url: 'https://cdn.yourco.com/products/tr2/side.jpg', name: 'side.jpg' }], }, 'evt_01J9…');
# Python import hashlib, hmac, json, os, time, uuid, requests HOOK_URL = os.environ["POOLDAY_HOOK_URL"] HOOK_SECRET = os.environ["POOLDAY_HOOK_SECRET"].encode() def send_to_poolday(event: dict, event_id: str | None = None) -> dict: body = json.dumps(event, separators=(",", ":")).encode() # sign exactly these bytes signature = "sha256=" + hmac.new(HOOK_SECRET, body, hashlib.sha256).hexdigest() headers = {"content-type": "application/json", "x-poolday-signature": signature, "idempotency-key": event_id or str(uuid.uuid4())} for attempt in range(1, 5): r = requests.post(HOOK_URL, data=body, headers=headers, timeout=30) if r.status_code in (200, 202): return r.json() if r.status_code < 500: raise RuntimeError(f"Poolday rejected the webhook ({r.status_code}): {r.text}") time.sleep(0.5 * 2 ** attempt) raise RuntimeError("Poolday webhook failed")
# curl, quick test BODY='{"event":"order.created","id":"ord_1","product":{"title":"Trail Runner 2"}}' SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$POOLDAY_HOOK_SECRET" | sed 's/^.* //')" curl -sS -X POST "$POOLDAY_HOOK_URL" -H 'content-type: application/json' \ -H "x-poolday-signature: $SIG" -H 'idempotency-key: evt_test_1' -d "$BODY" # prints {"accepted":1,...}
2.4 Writing the prompt
{{event.<path>}} inserts a value from the JSON you sent. Top-level keys directly ({{event.id}}), nested ones by path ({{event.product.title}}, {{event.items[0].sku}}). Unknown paths become empty. Poolday also appends the full JSON after your prompt, so the agent has everything you sent even if the prompt does not reference it.
Example prompt that returns the result through the MCP server from section 3:
A new order needs a product video.
Order {{event.id}}: {{event.product.title}} (SKU {{event.product.sku}}) for {{event.customer.name}}.
Make a 20-second vertical (9:16) product video from the attached product images: a punchy hook,
three feature beats, and an end card with the product name. No voice-over; light background music.
When the final render is done:
1. Call the `acme-orders` connector's `deliver_video` tool with orderId "{{event.id}}",
the rendered video URL, its duration, and a one-line description.
2. Then call `update_order_status` with orderId "{{event.id}}" and status "video_ready".
If you cannot finish (missing images, render failure), call `report_problem` with
orderId "{{event.id}}" and a short reason. Do not ask questions. Nobody is watching this chat.For prompts that run unattended: name the connector and the tools explicitly, give the agent a tool to call when it cannot finish, and tell it what to do instead of asking, because no one will answer.
3. Outbound: your MCP server
You expose a few tools. The agent calls them when it is done.
3.1 Requirements
| Requirement | Detail |
|---|---|
| Public https URL | Reachable from the internet. No plain http, no private or internal addresses. |
| MCP Streamable HTTP | The standard transport. SSE also works as a fallback. No stdio. |
| At least one tool | Poolday lists tools when you connect and rejects an empty server. |
| Auth: none, or OAuth + DCR | Poolday registers itself as an OAuth client through Dynamic Client Registration. Static API keys and bearer headers are not supported. You cannot paste a token into Poolday. |
A ping tool | Recommended. On connect, Poolday may call one read-only, no-argument tool to check that the server accepts calls. Give it an obvious safe one. |
Poolday opens a fresh connection for every tool call, so keep the server stateless. Return quickly, well under a minute. If the work takes longer, accept the call and finish asynchronously.
3.2 Which tools to expose
The agent picks tools by reading their descriptions, so write them precisely. Keep inputs flat. Make writes idempotent on the id the agent passes, since it may retry.
| Tool | Purpose |
|---|---|
| ping | Health check. Read-only, no arguments. |
| get_order | Lets the agent pull more context than the webhook carried. |
| deliver_video | The result channel: entity id, rendered video URL, metadata. |
| update_order_status | A state change in your system. |
| report_problem | What the agent calls when it cannot finish. Always provide one. |
Every tool on the server is callable by the agent. There is no per-tool allow-list on Poolday’s side, so do not expose destructive operations.
3.3 Reference implementation (Node / TypeScript)
npm i @modelcontextprotocol/sdk express zod
// mcp-server.ts: stateless Streamable HTTP, a fresh server and transport per request import express from 'express'; import { z } from 'zod'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { orders } from './orders.js'; // your domain layer const text = (v: unknown) => ({ content: [{ type: 'text' as const, text: typeof v === 'string' ? v : JSON.stringify(v) }] }); function createServer() { const server = new McpServer({ name: 'acme-orders', version: '1.0.0' }); server.registerTool('ping', { description: 'Health check. Returns "pong".', inputSchema: {}, annotations: { readOnlyHint: true } }, async () => text('pong')); server.registerTool('get_order', { description: 'Fetch an order by id: customer, line items, product copy, brand notes, image URLs.', inputSchema: { orderId: z.string().describe('Order id, e.g. ord_84213') }, annotations: { readOnlyHint: true } }, async ({ orderId }) => { const order = await orders.get(orderId); return order ? text(order) : { ...text(`Order ${orderId} not found`), isError: true }; }); server.registerTool('deliver_video', { description: 'Attach a finished video to an order. Call exactly once per order when the final render is done. ' + 'videoUrl must be the rendered MP4 URL; include duration and a one-line description.', inputSchema: { orderId: z.string(), videoUrl: z.string().url(), durationSeconds: z.number().positive().optional(), description: z.string().max(500).optional() }, annotations: { idempotentHint: true } }, async ({ orderId, videoUrl, ...meta }) => { // Copy the file into your own storage. Treat the Poolday URL as a download link. const delivery = await orders.attachVideo(orderId, { sourceUrl: videoUrl, ...meta }); return text({ ok: true, deliveryId: delivery.id }); }); server.registerTool('update_order_status', { description: 'Move an order to a new fulfilment status.', inputSchema: { orderId: z.string(), status: z.enum(['video_ready', 'needs_review', 'failed']) }, annotations: { idempotentHint: true } }, async ({ orderId, status }) => text(await orders.setStatus(orderId, status))); server.registerTool('report_problem', { description: 'Report that the video could not be produced for an order and why. Use instead of asking a human.', inputSchema: { orderId: z.string(), reason: z.string().max(2000) }, annotations: { idempotentHint: true } }, async ({ orderId, reason }) => text(await orders.flag(orderId, reason))); return server; } const app = express(); app.use(express.json({ limit: '1mb' })); app.post('/mcp', async (req, res) => { const server = createServer(); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); res.on('close', () => { transport.close(); server.close(); }); await server.connect(transport); await transport.handleRequest(req, res, req.body); }); app.get('/mcp', (_req, res) => res.status(405).set('Allow', 'POST').end()); app.delete('/mcp', (_req, res) => res.status(405).set('Allow', 'POST').end()); app.listen(8080); // put TLS in front: https://mcp.yourco.com/mcp
Check it before connecting:
npx @modelcontextprotocol/inspector --cli https://mcp.yourco.com/mcp --method tools/list npx @modelcontextprotocol/inspector --cli https://mcp.yourco.com/mcp --method tools/call --tool-name ping
3.4 Securing it
Option A: an unguessable URL
The simplest option. Serve the endpoint on a random path, for example https://mcp.yourco.com/mcp/<32 random bytes, hex>, rate-limit it, and keep the tools low-risk. The tools above can only touch an order whose id the agent already has. Poolday shows the URL only to the user who added it.
Option B: OAuth with Dynamic Client Registration
Use this if you need per-user identity or the ability to revoke access. Requirements:
- Unauthenticated requests get
401withWWW-Authenticate: Bearer resource_metadata=“…”. - Serve
/.well-known/oauth-protected-resource(RFC 9728) pointing at your authorization server. - The authorization server publishes a
registration_endpoint(DCR, RFC 7591), supports PKCE S256,authorization_codeandrefresh_token, and public clients (no client secret). - It accepts the redirect URI Poolday registers:
https://<poolday-api-host>/agent-integrations/mcp/<connector-id>/callback. If you allow-list redirect URIs, allowhttps://*.poolday.click/agent-integrations/mcp/*/callbackandhttps://api*.poolday.ai/agent-integrations/mcp/*/callback.
With the TypeScript SDK you can front an identity provider that supports DCR (Auth0, Keycloak, Okta):
import { ProxyOAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js'; import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js'; import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js'; const IDP = 'https://auth.yourco.com'; const provider = new ProxyOAuthServerProvider({ endpoints: { authorizationUrl: `${IDP}/authorize`, tokenUrl: `${IDP}/oauth/token`, revocationUrl: `${IDP}/oauth/revoke`, registrationUrl: `${IDP}/oidc/register` }, // DCR, required verifyAccessToken: async (token) => { const claims = await verifyJwt(token, { issuer: IDP, audience: 'https://mcp.yourco.com' }); return { token, clientId: claims.azp, scopes: claims.scope?.split(' ') ?? [], expiresAt: claims.exp }; }, getClient: async (clientId) => ({ client_id: clientId, redirect_uris: [] }), }); app.use(mcpAuthRouter({ provider, issuerUrl: new URL('https://mcp.yourco.com'), resourceServerUrl: new URL('https://mcp.yourco.com/mcp') })); app.post('/mcp', requireBearerAuth({ verifier: provider, resourceMetadataUrl: 'https://mcp.yourco.com/.well-known/oauth-protected-resource' }), /* handler above */);
When you connect the server in Poolday you go through your identity provider’s consent screen once. Poolday keeps the tokens and refreshes them. If a refresh fails, the connector shows as expired and automations that use it pause until you reconnect.
3.5 Connect it in Poolday
Signed in as the same user who owns the automation:
Capabilities > Connectors > Add MCP server
| Field | Value |
|---|---|
| Server URL | https://mcp.yourco.com/mcp |
| Name | Acme Orders. The connector id becomes acme-orders, which is what you reference in prompts. |
Poolday tests the server and shows it as Connected. If it refuses, check that the URL is https and public, that tools/list returns something, and, for OAuth, that dynamic registration is enabled.
3.6 How the agent uses it
There is nothing to wire per tool. The agent discovers your tools by itself, and your prompt tells it when to call them. On your server a call arrives as a normal MCP tools/call:
{ "method": "tools/call",
"params": { "name": "deliver_video",
"arguments": { "orderId": "ord_84213",
"videoUrl": "https://media.poolday.ai/…/final.mp4",
"durationSeconds": 20,
"description": "20s vertical hero for Trail Runner 2" } } }Download the video from the URL you receive and store it yourself. It is a delivery link, not permanent storage.
4. End to end
4.1 The flow
- Your system. An order is placed and you call
sendToPoolday({ event: 'order.created', id: 'ord_84213', …, files }). Poolday answers202in a fraction of a second. - Poolday. Downloads the images, fills in your prompt, starts a conversation. With one conversation per key on
id, a later event aboutord_84213continues the same chat. - The agent. Reads the event, calls
get_orderif it needs more, builds and renders the video, then callsdeliver_videoandupdate_order_statuson your server. - Your server. Stores the MP4 and moves the order to
video_ready. - If something fails. The agent calls
report_problem. If your organization is out of credits, the run is skipped and shows as such on the Automations page.
4.2 Checklist
- Webhook: a wrong signature returns
401, a correct one returns202 accepted:1, and the sameIdempotency-Keysent again returns202 deduplicated:1. - Automations > your automation > Run now fires a sample event so you can watch the prompt in a real conversation.
npx @modelcontextprotocol/inspector --cli <url> --method tools/listlists your tools, and--tool-name pingreturnspong.- The Connectors page shows your server as Connected for the integration user.
- Send a real event.
deliver_videoarrives on your server with the rightorderId. - Rotate the webhook secret once and confirm your deployment picks up the new URL and secret.
5. Limits
| Topic | Value |
|---|---|
| Runs per automation | 60 per hour by default (editable on the automation); 300 per organization per hour |
| Duplicate detection | By Idempotency-Key; otherwise an identical body within 5 minutes |
| Files per event | 10 files, 50 MB each, 200 MB total |
| Same-conversation targets | If the previous run is still going, the new event waits up to 30 minutes, then is skipped |
| Tool call | Must complete within 2 minutes; results over 6 KB are handed to the agent as a file |
| MCP auth | None, or OAuth + DCR. No static keys. |
| Scope | Automations run as their creator; connectors belong to the user who added them |
6. Notes
- No static credentials for MCP servers. A server that authenticates with a fixed token or API-key header cannot be connected. Use an unguessable URL or OAuth with DCR.
- The video URL the agent hands you is a delivery link. Copy the file into your own storage on receipt.
- Use the webhook URL exactly as revealed. Poolday’s API host changes over time. The URL you are given is always the right one.