Poolday

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 back

The 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

FieldValue
NameAnything descriptive, for example Order to product video
PromptWhat the agent should do. Use {{event.…}} to insert fields from the JSON you send. See 2.4.
Mode / tierWhatever you would pick in the composer for this kind of job
Ask questionsOff. Nobody is watching the conversation, so the prompt has to say what to do when the agent is blocked.
TargetNew 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 requestsOn
Idempotency headerIdempotency-Key, or your own header name such as X-Event-Id
Attach filesOn 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... }
PartRule
BodyAny JSON. There is no required shape. Everything you send is available to the prompt.
SignatureHMAC-SHA256 of the exact bytes of the body, keyed with the secret, formatted as sha256=<hex>. Serialize once and send that same string.
Idempotency-KeyYour 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.
FilesAdd "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

StatusMeaning
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.
401Wrong URL token or bad signature.
404Unknown automation. The URL is wrong or was rotated.
5xxRetry 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

RequirementDetail
Public https URLReachable from the internet. No plain http, no private or internal addresses.
MCP Streamable HTTPThe standard transport. SSE also works as a fallback. No stdio.
At least one toolPoolday lists tools when you connect and rejects an empty server.
Auth: none, or OAuth + DCRPoolday 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 toolRecommended. 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.

ToolPurpose
pingHealth check. Read-only, no arguments.
get_orderLets the agent pull more context than the webhook carried.
deliver_videoThe result channel: entity id, rendered video URL, metadata.
update_order_statusA state change in your system.
report_problemWhat 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:

  1. Unauthenticated requests get 401 with WWW-Authenticate: Bearer resource_metadata=“…”.
  2. Serve /.well-known/oauth-protected-resource (RFC 9728) pointing at your authorization server.
  3. The authorization server publishes a registration_endpoint (DCR, RFC 7591), supports PKCE S256, authorization_code and refresh_token, and public clients (no client secret).
  4. It accepts the redirect URI Poolday registers: https://<poolday-api-host>/agent-integrations/mcp/<connector-id>/callback. If you allow-list redirect URIs, allow https://*.poolday.click/agent-integrations/mcp/*/callback and https://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

FieldValue
Server URLhttps://mcp.yourco.com/mcp
NameAcme 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

  1. Your system. An order is placed and you call sendToPoolday({ event: 'order.created', id: 'ord_84213', …, files }). Poolday answers 202 in a fraction of a second.
  2. Poolday. Downloads the images, fills in your prompt, starts a conversation. With one conversation per key on id, a later event about ord_84213 continues the same chat.
  3. The agent. Reads the event, calls get_order if it needs more, builds and renders the video, then calls deliver_video and update_order_status on your server.
  4. Your server. Stores the MP4 and moves the order to video_ready.
  5. 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 returns 202 accepted:1, and the same Idempotency-Key sent again returns 202 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/list lists your tools, and --tool-name ping returns pong.
  • The Connectors page shows your server as Connected for the integration user.
  • Send a real event. deliver_video arrives on your server with the right orderId.
  • Rotate the webhook secret once and confirm your deployment picks up the new URL and secret.

5. Limits

TopicValue
Runs per automation60 per hour by default (editable on the automation); 300 per organization per hour
Duplicate detectionBy Idempotency-Key; otherwise an identical body within 5 minutes
Files per event10 files, 50 MB each, 200 MB total
Same-conversation targetsIf the previous run is still going, the new event waits up to 30 minutes, then is skipped
Tool callMust complete within 2 minutes; results over 6 KB are handed to the agent as a file
MCP authNone, or OAuth + DCR. No static keys.
ScopeAutomations 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.