Goldsky Turbo Polymarket Bot
Build a guarded bot that turns Polymarket fills into capped trade signals.
Introduction
30 Second Summary
Prediction-market prices can shift between the moment a trade catches your eye and the moment you act. That gap matters even more when software reacts on your behalf.
In this project, you will build a local trading bot for one Polymarket YES outcome using Goldsky Turbo Pipelines. The bot turns filtered on-chain fills into guarded trade decisions that require your typed approval.
What You'll Build
You'll watch a qualifying Polymarket fill reach your Mac through Goldsky before your bot approves one tightly capped order or stops with a clear safety reason.
By the end of this project, you'll have:
- A market discovery command that prints the question, YES token ID, current ask, and trading status for your chosen market.
- An authenticated signal path that receives Goldsky webhook events on your Mac while ignoring a replay of the same event.
- A guarded execution flow that re-checks the current ask before allowing one price-capped, spending-capped, human-approved order.
- Secret Mission: pause and resume the Goldsky Turbo pipeline, then prove its checkpointed stream continues without starting over.
Are there any prerequisites?
You need a funded Polymarket account that is eligible to trade from your actual location. You also need access to the account's signer private key plus its wallet address.
Before We Start
Trading decisions become safer when your boundaries are fixed before a signal arrives. This checkpoint records your active Polymarket market plus your YES price ceiling plus your all-in cap of no more than 10 pUSD plus your reason for trading.
Set Up the Local Project
The current Polymarket TypeScript SDK requires Node.js 24 or newer. An older runtime can fail before the bot reaches its safety checks.
This step gives the bot a reproducible TypeScript workspace in Cursor. The pinned package file keeps every dependency consistent throughout the project.
In this step, get ready to:
- Confirm Node.js 24 or newer is available with npm.
- Create the supplied project structure in goldsky-polymarket-bot.
- Prepare the ignored local environment file before installing the pinned dependencies.
Choose the runtime path
The SDK checks the Node.js runtime before your trading logic runs. Checking the installed release now prevents confusing compatibility failures later.
- Inspect the installed runtime by running these checks in a terminal:
node --version
npm --version
What do these checks show?
The first command reports the active Node.js release on your PATH. The second confirms that npm is available for installing the project packages.
Choose the tab that matches the Node.js result in your terminal.
✔️ I see the required release
Your runtime is compatible when the major version is 24 or higher. npm is also ready when its command prints a version number.
Good progress. Your machine can run the exact SDK release used by this project.
ⓧ I see an older release
The Homebrew formula installs the required Node.js release. The formula is keg-only, so Homebrew may print a PATH instruction after installation.
- Install the required formula by running this command:
brew install node@24
What does this command do?
Homebrew installs the node@24 formula. This provides the Node.js runtime required by @polymarket/client.
- Follow the PATH instruction printed by Homebrew after the installation finishes.
- Start a fresh terminal session so the updated PATH takes effect.
- Recheck the active runtime by running these commands:
node --version
npm --version
What should change?
The new terminal should resolve the Homebrew installation. You should now see Node.js v24.21.0 or another release with a major version of at least 24.
Still seeing the older runtime?
Check that you followed the PATH instruction printed by Homebrew. Start another terminal session after making the change.
If the older release remains active, help me check which Node.js installation my PATH is using.
ⓧ The command is unavailable
The required runtime is missing from your PATH. Homebrew can install the compatible formula.
- Install the required formula by running this command:
brew install node@24
What does this command install?
Homebrew installs Node.js 24 with npm. The formula provides the runtime needed by the pinned project dependencies.
- Follow the PATH instruction printed by Homebrew after the installation finishes.
- Start a fresh terminal session so the updated PATH takes effect.
- Confirm both commands are now available by running:
node --version
npm --version
What confirms success?
Node.js should report v24.21.0 or another release with a major version of at least 24. npm should print its own version number.
Installation still unavailable?
Confirm that Homebrew completed the installation. Check that your new terminal uses the PATH instruction Homebrew printed.
If the commands remain unavailable, help me make the Homebrew Node.js installation available in my terminal.
Create the project files
The starter files separate package settings from local configuration. They also provide the scripts that later steps use to discover the market and test duplicate webhook delivery.
- Press Cmd+Space on macOS or the Windows key on Windows to open the system search bar.
- Type Cursor into the search bar before pressing Enter.
- Create a Desktop folder named goldsky-polymarket-bot with Cursor's folder picker.
- Set goldsky-polymarket-bot as the active folder in Cursor.
Cursor now has one named location for every file in the bot.
- Create a folder named src inside goldsky-polymarket-bot.
- Create package.json inside goldsky-polymarket-bot.
- Create tsconfig.json inside goldsky-polymarket-bot.
- Create .gitignore inside goldsky-polymarket-bot.
- Create .env.example inside goldsky-polymarket-bot.
- Create goldsky-pipeline.yaml inside goldsky-polymarket-bot.
How are the setup files divided?
The package file pins the runtime scripts and dependencies. The TypeScript configuration gives the compiler strict project rules.
The ignore file protects local configuration from Git. The environment template records every setting the bot expects.
The pipeline template holds the later Goldsky Turbo Pipelines source and webhook structure. It remains a template during this setup step.
- Create discover.ts inside src.
- Create send-test.ts inside src.
- Create server.ts inside src.
- Fill each empty file with the matching content from the full-code reference below.
✔️ Awesome, I've got everything!
Your starter project contains the package settings, environment template, source scripts, server scaffold, and pipeline template.
ⓧ I'd like to double check the full code
{
"name": "goldsky-polymarket-bot",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=24"
},
"scripts": {
"discover": "tsx src/discover.ts",
"dev": "tsx src/server.ts",
"send-test": "tsx src/send-test.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@polymarket/client": "0.10.0",
"viem": "2.56.5"
},
"devDependencies": {
"@types/node": "26.5.1",
"tsx": "4.23.13",
"typescript": "6.0.3"
}
}
What does this file control?
The engine rule requires Node.js >=24. The scripts connect npm to the three source entry points and the compiler check.
The dependencies pin @polymarket/client to 0.10.0. Viem is pinned to 2.56.5 for signer support.
{
"compilerOptions": {
"target": "ES2024",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
What does this configuration enforce?
The compiler checks the source files with strict typing. It uses Node.js module resolution without emitting compiled files.
node_modules/
.env
.DS_Store
What stays out of Git?
Installed packages stay out of version control. The local environment file stays out because it contains signer-sensitive values.
The macOS metadata file also stays out of the repository.
MARKET_URL=https://polymarket.com/event/your-market-specific-link
YES_TOKEN_ID=replace-after-running-discover
MAX_PRICE=0.50
MAX_SPEND=10
WEBHOOK_TOKEN=replace-with-a-long-random-value
TEST_WEBHOOK_URL=http://127.0.0.1:3000/goldsky
PORT=3000
LIVE_TRADING=false
POLYMARKET_WALLET_ADDRESS=0x-your-polymarket-wallet-address
POLYMARKET_PRIVATE_KEY=0x-your-local-signer-private-key
What does the environment template cover?
The template lists the market controls and webhook settings. It also lists the local wallet values required for the guarded live path.
The template keeps live trading disabled with LIVE_TRADING=false.
import { createPublicClient, OrderSide } from "@polymarket/client";
process.loadEnvFile();
function required(name: string): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`Missing ${name} in .env`);
}
return value;
}
const client = createPublicClient();
const market = await client.fetchMarket({ url: required("MARKET_URL") });
const yesTokenId = market.outcomes.yes.tokenId;
if (!yesTokenId) {
throw new Error("The selected market does not expose a YES token ID.");
}
const currentAsk = await client.fetchPrice({
tokenId: yesTokenId,
side: OrderSide.BUY,
});
console.log({
question: market.question,
marketId: market.id,
conditionId: market.conditionId,
yesTokenId,
currentAsk,
active: market.state.active,
closed: market.state.closed,
acceptingOrders: market.state.acceptingOrders,
minimumOrderSize: market.trading.minimumOrderSize,
});
console.log(`Add this to .env: YES_TOKEN_ID=${yesTokenId}`);
What does the discovery script prepare?
The script loads the selected market from its URL. It prints the YES token ID with the current BUY price and trading status.
process.loadEnvFile();
function required(name: string): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`Missing ${name} in .env`);
}
return value;
}
const endpoint = required("TEST_WEBHOOK_URL");
const token = required("WEBHOOK_TOKEN");
const threshold = Number(required("MAX_PRICE"));
const spend = Number(required("MAX_SPEND"));
const eventId = `test-fill-${Date.now()}`;
const event = {
id: eventId,
block_number: 0,
block_timestamp: Math.floor(Date.now() / 1000),
transaction_hash: "test-transaction",
user_id: "test-user",
asset: required("YES_TOKEN_ID"),
side: "BUY",
price: threshold,
amount_usdc: spend,
amount_shares: spend / threshold,
};
for (let attempt = 1; attempt <= 2; attempt += 1) {
const response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(event),
});
console.log(`Attempt ${attempt}: ${response.status} ${await response.text()}`);
}
What does the test script prepare?
The script creates one test fill event. It sends that same event ID twice so the next steps can expose duplicate processing before fixing it.
import { Buffer } from "node:buffer";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { stdin as input, stdout as output } from "node:process";
import { createInterface } from "node:readline/promises";
import {
createPublicClient,
createSecureClient,
OrderSide,
OrderType,
} from "@polymarket/client";
import { privateKey } from "@polymarket/client/viem";
process.loadEnvFile();
type GoldskyFill = {
id: string;
block_number: number;
block_timestamp: number;
transaction_hash: string;
user_id: string;
asset: string;
side: string;
price: number;
amount_usdc: number;
amount_shares: number;
};
type GeoblockResponse = {
blocked: boolean;
ip: string;
country: string;
region: string;
};
function required(name: string): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`Missing ${name} in .env`);
}
return value;
}
function numberSetting(name: string, minimum: number, maximum: number): number {
const value = Number(required(name));
if (!Number.isFinite(value) || value < minimum || value > maximum) {
throw new Error(`${name} must be between ${minimum} and ${maximum}.`);
}
return value;
}
function sendJson(response: ServerResponse, status: number, body: unknown): void {
response.writeHead(status, { "Content-Type": "application/json" });
response.end(JSON.stringify(body));
}
async function readJson(request: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
let totalBytes = 0;
for await (const chunk of request) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
totalBytes += buffer.length;
if (totalBytes > 100_000) {
throw new Error("Request body is too large.");
}
chunks.push(buffer);
}
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
function isGoldskyFill(value: unknown): value is GoldskyFill {
if (typeof value !== "object" || value === null) {
return false;
}
const fill = value as Partial<GoldskyFill>;
return (
typeof fill.id === "string" &&
typeof fill.asset === "string" &&
typeof fill.side === "string" &&
typeof fill.price === "number" &&
typeof fill.amount_usdc === "number" &&
typeof fill.amount_shares === "number"
);
}
What does the server scaffold provide?
The scaffold loads local settings and defines the webhook event shape. It also provides validation helpers for later receiver logic.
Later steps complete the routes and guarded order path using these shared foundations.
name: goldsky-polymarket-trigger
resource_size: s
sources:
order_filled:
type: dataset
dataset_name: polymarket.order_filled
version: 2.0.0
start_at: latest
transforms:
matching_fills:
type: sql
primary_key: id
sql: |
SELECT
id,
block_number,
block_timestamp,
transaction_hash,
user_id,
asset,
side,
price,
amount_usdc,
amount_shares
FROM order_filled
WHERE asset = 'YOUR_YES_TOKEN_ID'
AND side = 'BUY'
AND price <= 0.50
sinks:
local_bot:
type: webhook
from: matching_fills
url: https://YOUR-QUICK-TUNNEL.trycloudflare.com/goldsky
secret_name: POLYMARKET_BOT_WEBHOOK
one_row_per_request: true
What does the pipeline template describe?
The source targets polymarket.order_filled version 2.0.0 from the latest checkpoint. The SQL transform selects matching YES-side BUY fills.
The webhook sink contains placeholders for the market token and temporary public URL. A later step replaces those values before deployment.
- Save every project file in Cursor.
- Confirm Cursor lists the five top-level files plus the three TypeScript files inside src.
You should now see package.json, tsconfig.json, .gitignore, .env.example, goldsky-pipeline.yaml, and the src folder in your project.
Missing a project file?
Check that each filename matches the reference exactly. Confirm the three TypeScript files sit inside src.
If the structure still looks different, help me compare my Cursor project tree with the required file structure.
Protect local settings and install dependencies
The committed template documents every required setting. Your local .env holds the real market choices and signer credentials.
- Duplicate .env.example as .env inside goldsky-polymarket-bot.
- Replace https://polymarket.com/event/your-market-specific-link with the active market URL you selected.
- Leave YES_TOKEN_ID=replace-after-running-discover in place for the discovery step.
- Set MAX_PRICE to the maximum YES price you chose.
- Set MAX_SPEND to your chosen all-in cap of no more than 10.
- Leave TEST_WEBHOOK_URL=http://127.0.0.1:3000/goldsky, PORT=3000, and LIVE_TRADING=false unchanged.
This is the sensitive part of setup. Your signer values stay in the ignored local .env file.
- Replace replace-with-a-long-random-value with a long random webhook token.
- Replace 0x-your-polymarket-wallet-address with your Polymarket account wallet address.
- Replace 0x-your-local-signer-private-key with the corresponding local signer private key.
- Save .env before closing its editor tab.
- Confirm .gitignore contains a separate .env line.
Keep credentials off-screen
Keep .env closed whenever you take screenshots. Never paste the signer private key into chat messages or project validations.
- Start a terminal in Cursor for the goldsky-polymarket-bot folder.
Before you run the final check, which Node.js major version do you expect the terminal to report?
- Verify the runtime and install the pinned dependencies by running these commands:
node --version
npm install
What do these final checks prove?
The first command proves that Cursor's terminal can reach the compatible Node.js runtime. The second reads the exact package pins from package.json.
npm installs the runtime dependencies with the compiler-only development packages. The generated package state now matches the project configuration.
You have cleared the compatibility hurdle: node --version prints v24.21.0 or another release with a major version of at least 24. The dependency installation completes without errors.
Dependency installation failed?
Confirm the terminal is running inside goldsky-polymarket-bot. Compare package.json with the reference tab for missing punctuation or changed version pins.
If the installation still fails, help me diagnose my pinned npm dependency installation.
Your local foundation is ready. Next, you will resolve the selected market and watch the first webhook receiver process a duplicate signal.
Build the Local Signal Receiver
Your local TypeScript project is ready in Cursor. The next job is to bind it to one real Polymarket market.
A local webhook proves that the market path can receive a fill signal. Replaying one event lets you observe how this first receiver handles repeated delivery.
In this step, get ready to:
- Resolve one market to its YES token plus its current BUY ask.
- Run a local receiver with authenticated health plus signal routes.
- Replay one test signal to observe repeated delivery.
Choose and resolve the market
A market-specific URL identifies the outcome your bot watches. The discovery script converts that page into the YES token used by every later price check.
- Open Polymarket in your browser.
- Choose an active market with a YES outcome you genuinely want to monitor.
- Copy the full selected-market URL from your browser address bar.
Choose One Specific Market
Some event pages contain multiple markets. A selected-market URL helps fetchMarket() resolve one unambiguous YES token.
Credentials deserve a careful pass. You only change the market plus webhook settings in .env during this step.
The file remains excluded from Git. Your signer credentials stay outside version control.
- Select .env from Cursor's file sidebar.
- Replace the MARKET_URL value with the URL you copied.
- Set MAX_PRICE to the highest YES price you genuinely accept.
- Set MAX_SPEND to a spending cap no greater than 10 pUSD.
- Replace the WEBHOOK_TOKEN placeholder with a long random value that you keep private.
- Confirm that LIVE_TRADING=false remains unchanged.
- Save .env.
- Resolve the selected market by running this command:
npm run discover
What Should I See?
- Find the market question in the output.
- Find the marketId value.
- Find the conditionId value.
- Find the yesTokenId value.
- Find the current BUY ask.
- Find the active status.
- Find the closed status.
- Find the acceptingOrders status.
- Find the minimumOrderSize value.
Discovery Script Stopped?
Check that MARKET_URL contains the complete selected-market URL. Confirm that the setting has no spaces around its value.
If the market has no YES token, choose another active market. If it has stopped accepting orders, choose another active market.
Help me diagnose my market discovery output.
- Copy the YES token ID from the final assignment line.
- Replace the placeholder YES_TOKEN_ID value in .env.
- Save .env.
Complete the local receiver
An on-chain fill records a completed trade. Its price can differ from the BUY ask available when your receiver processes the signal.
The receiver validates each request before fetching a fresh BUY ask. Live order submission stays disabled through LIVE_TRADING=false.
- Return to src/server.ts in Cursor.
- Press Cmd+A on macOS or Ctrl+A on Windows to select its current contents.
- Replace the selected contents with the complete local receiver shown in the second tab below.
✔️ Awesome, I've got everything!
Your receiver now matches the local signal path for this step.
ⓧ I'd like to double check the full code
import { Buffer } from "node:buffer";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import {
createPublicClient,
OrderSide,
} from "@polymarket/client";
process.loadEnvFile();
type GoldskyFill = {
id: string;
block_number: number;
block_timestamp: number;
transaction_hash: string;
user_id: string;
asset: string;
side: string;
price: number;
amount_usdc: number;
amount_shares: number;
};
function required(name: string): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`Missing ${name} in .env`);
}
return value;
}
function numberSetting(name: string, minimum: number, maximum: number): number {
const value = Number(required(name));
if (!Number.isFinite(value) || value < minimum || value > maximum) {
throw new Error(`${name} must be between ${minimum} and ${maximum}.`);
}
return value;
}
function sendJson(response: ServerResponse, status: number, body: unknown): void {
response.writeHead(status, { "Content-Type": "application/json" });
response.end(JSON.stringify(body));
}
async function readJson(request: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
let totalBytes = 0;
for await (const chunk of request) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
totalBytes += buffer.length;
if (totalBytes > 100_000) {
throw new Error("Request body is too large.");
}
chunks.push(buffer);
}
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
function isGoldskyFill(value: unknown): value is GoldskyFill {
if (typeof value !== "object" || value === null) {
return false;
}
const fill = value as Partial<GoldskyFill>;
return (
typeof fill.id === "string" &&
typeof fill.asset === "string" &&
typeof fill.side === "string" &&
typeof fill.price === "number" &&
typeof fill.amount_usdc === "number" &&
typeof fill.amount_shares === "number"
);
}
const marketUrl = required("MARKET_URL");
const configuredYesTokenId = required("YES_TOKEN_ID");
const webhookToken = required("WEBHOOK_TOKEN");
const maximumPrice = numberSetting("MAX_PRICE", 0.0001, 1);
const maximumSpend = numberSetting("MAX_SPEND", 1, 10);
const port = numberSetting("PORT", 1, 65535);
const liveTrading = process.env.LIVE_TRADING === "true";
const publicClient = createPublicClient();
const market = await publicClient.fetchMarket({ url: marketUrl });
const yesTokenId = market.outcomes.yes.tokenId;
if (!yesTokenId) {
throw new Error("The selected market does not expose a YES token ID.");
}
if (yesTokenId !== configuredYesTokenId) {
throw new Error("YES_TOKEN_ID does not match the selected market URL.");
}
if (!market.state.active || market.state.closed || !market.state.acceptingOrders) {
throw new Error("The selected market is not currently accepting orders.");
}
const startupAsk = await publicClient.fetchPrice({
tokenId: yesTokenId,
side: OrderSide.BUY,
});
console.log({
question: market.question,
yesTokenId,
startupAsk,
maximumPrice,
maximumSpend,
liveTrading,
});
let processingQueue = Promise.resolve();
async function processSignal(fill: GoldskyFill): Promise<void> {
console.log("Signal received", {
id: fill.id,
side: fill.side,
triggerPrice: fill.price,
amountUsdc: fill.amount_usdc,
});
if (fill.asset !== yesTokenId || fill.side !== "BUY") {
console.log("Signal does not match the configured YES-side filter.");
return;
}
if (fill.price > maximumPrice) {
console.log("Signal price is above MAX_PRICE.");
return;
}
const currentAskText = await publicClient.fetchPrice({
tokenId: yesTokenId,
side: OrderSide.BUY,
});
const currentAsk = Number(currentAskText);
console.log("Current quote", {
triggerPrice: fill.price,
currentAsk,
maximumPrice,
maximumSpend,
});
if (!Number.isFinite(currentAsk) || currentAsk > maximumPrice) {
console.log("Current ask is no longer acceptable. No order submitted.");
return;
}
if (!liveTrading) {
console.log("DRY RUN: the signal passed all market checks.");
return;
}
}
const server = createServer(async (request, response) => {
try {
if (request.method === "GET" && request.url === "/health") {
sendJson(response, 200, {
ok: true,
market: market.question,
liveTrading,
});
return;
}
if (request.method !== "POST" || request.url !== "/goldsky") {
sendJson(response, 404, { error: "Not found" });
return;
}
if (request.headers.authorization !== `Bearer ${webhookToken}`) {
sendJson(response, 401, { error: "Unauthorized" });
return;
}
const body = await readJson(request);
if (!isGoldskyFill(body)) {
sendJson(response, 400, { error: "Invalid Goldsky fill payload" });
return;
}
sendJson(response, 202, { accepted: true });
processingQueue = processingQueue
.then(() => processSignal(body))
.catch((error: unknown) => {
console.error("Signal processing failed", error);
});
} catch (error) {
console.error("Webhook request failed", error);
sendJson(response, 500, { error: "Internal server error" });
}
});
server.listen(port, "127.0.0.1", () => {
console.log(`Listening on http://127.0.0.1:${port}`);
});
How Does the Receiver Work?
- The startup checks bind the configured market URL to its discovered YES token.
- The /health route reports the selected market.
- The /goldsky route checks the bearer token before reading the payload.
- The payload guard checks the fields needed for a fill signal.
- The signal processor fetches a fresh BUY ask before producing a dry-run result.
- The processing queue handles accepted signals in arrival order.
- Save src/server.ts.
- Start the local receiver by running this command:
npm run dev
What Should I See?
- Find the market question in the startup output.
- Find the resolved YES token.
- Find the startup BUY ask.
- Find your maximum price plus spending cap.
- Confirm that liveTrading: false appears.
- Confirm that the final line shows http://127.0.0.1:3000.
The development process keeps this terminal occupied while the server waits for requests. Leave it running for the duplicate-delivery test.
Receiver Did Not Start?
A token mismatch means YES_TOKEN_ID does not belong to the current MARKET_URL. Run the discovery script again before copying its latest token.
A port error can mean another process already uses port 3000. Stop the earlier receiver process before restarting this one.
Help me diagnose why my local receiver does not start.
Replay the same event twice
The existing src/send-test.ts script creates one Goldsky-shaped BUY fill. Both requests carry the same event ID.
The fill includes the configured YES token plus your test price. It also includes the spending amount used for the preview.
- Add a second terminal panel from Cursor's terminal area.
- Switch to the new terminal panel.
Before you run the test, do you think the receiver will recognize that both requests represent the same event?
- Send the repeated test event by running this command:
npm run send-test
What Should I See?
The test terminal prints two successful attempt responses. Both requests receive a 202 status.
The receiver terminal prints Signal received twice for the same event ID. It also prints Current quote twice.
That is the first live signal loop working. One market event now reaches your local receiver through two successful deliveries.
Why Did Both Copies Run?
Each request has valid authorization. Each request also has a valid fill payload.
This receiver does not remember handled event IDs. Both copies therefore enter the price-checking path.
Repeated processing could create repeated trade attempts once live execution exists. Event identity becomes a required trading safeguard.
Only Saw One Preview?
Confirm that the test terminal printed both attempt lines. Check that TEST_WEBHOOK_URL still points to the local /goldsky endpoint.
Leave the receiver running until both queued price checks finish. The second quote can appear after the first market request completes.
Help me find why both test deliveries are not reaching my receiver.
Your local signal path works. Next, you will secure its public endpoint against unauthorized requests plus repeated event IDs.
Secure the Public Webhook
Your local Polymarket receiver now resolves one market. The naive TypeScript version also processes the same event twice.
Goldsky Turbo Pipelines uses at-least-once delivery. Retries can send the same event more than once. A public tunnel also exposes the receiver to internet traffic.
This step protects the endpoint with bearer authorization. It adds event-ID idempotency so each accepted event reaches the trading path once.
The processing queue serializes accepted signals after the fast acknowledgement. The one-trade lock provides a second barrier against repeated execution.
In this step, get ready to:
- Authorize every public webhook delivery.
- Prevent repeated trade attempts with event-ID idempotency.
- Prove duplicate suppression through a public Quick Tunnel.
Add the receiver guards
A webhook acknowledgement confirms that the receiver accepted an event. Queued processing lets the sender receive that response before the bot performs slower market checks.
- In Cursor's Explorer sidebar, select src/server.ts.
- Update src/server.ts so it matches the guarded version in the second tab below.
✔️ I've updated the receiver
Your receiver now has the complete authorization, acknowledgement, queue, duplicate-suppression, dry-run, and one-trade safeguards.
ⓧ I'd like to double check the full code
import { Buffer } from "node:buffer";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { stdin as input, stdout as output } from "node:process";
import { createInterface } from "node:readline/promises";
import {
createPublicClient,
createSecureClient,
OrderSide,
OrderType,
} from "@polymarket/client";
import { privateKey } from "@polymarket/client/viem";
process.loadEnvFile();
type GoldskyFill = {
id: string;
block_number: number;
block_timestamp: number;
transaction_hash: string;
user_id: string;
asset: string;
side: string;
price: number;
amount_usdc: number;
amount_shares: number;
};
type GeoblockResponse = {
blocked: boolean;
ip: string;
country: string;
region: string;
};
function required(name: string): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`Missing ${name} in .env`);
}
return value;
}
function numberSetting(name: string, minimum: number, maximum: number): number {
const value = Number(required(name));
if (!Number.isFinite(value) || value < minimum || value > maximum) {
throw new Error(`${name} must be between ${minimum} and ${maximum}.`);
}
return value;
}
function sendJson(response: ServerResponse, status: number, body: unknown): void {
response.writeHead(status, { "Content-Type": "application/json" });
response.end(JSON.stringify(body));
}
async function readJson(request: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
let totalBytes = 0;
for await (const chunk of request) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
totalBytes += buffer.length;
if (totalBytes > 100_000) {
throw new Error("Request body is too large.");
}
chunks.push(buffer);
}
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
function isGoldskyFill(value: unknown): value is GoldskyFill {
if (typeof value !== "object" || value === null) {
return false;
}
const fill = value as Partial<GoldskyFill>;
return (
typeof fill.id === "string" &&
typeof fill.asset === "string" &&
typeof fill.side === "string" &&
typeof fill.price === "number" &&
typeof fill.amount_usdc === "number" &&
typeof fill.amount_shares === "number"
);
}
const marketUrl = required("MARKET_URL");
const configuredYesTokenId = required("YES_TOKEN_ID");
const webhookToken = required("WEBHOOK_TOKEN");
const maximumPrice = numberSetting("MAX_PRICE", 0.0001, 1);
const maximumSpend = numberSetting("MAX_SPEND", 1, 10);
const maximumSpendText = String(maximumSpend);
const port = numberSetting("PORT", 1, 65535);
const liveTrading = process.env.LIVE_TRADING === "true";
const publicClient = createPublicClient();
const market = await publicClient.fetchMarket({ url: marketUrl });
const yesTokenId = market.outcomes.yes.tokenId;
if (!yesTokenId) {
throw new Error("The selected market does not expose a YES token ID.");
}
if (yesTokenId !== configuredYesTokenId) {
throw new Error("YES_TOKEN_ID does not match the selected market URL.");
}
if (!market.state.active || market.state.closed || !market.state.acceptingOrders) {
throw new Error("The selected market is not currently accepting orders.");
}
const startupAsk = await publicClient.fetchPrice({
tokenId: yesTokenId,
side: OrderSide.BUY,
});
console.log({
question: market.question,
yesTokenId,
startupAsk,
maximumPrice,
maximumSpend,
liveTrading,
});
const seenEventIds = new Set<string>();
let tradeAttempted = false;
let processingQueue = Promise.resolve();
async function processSignal(fill: GoldskyFill): Promise<void> {
console.log("Signal received", {
id: fill.id,
side: fill.side,
triggerPrice: fill.price,
amountUsdc: fill.amount_usdc,
});
if (tradeAttempted) {
console.log("One-trade lock active. Signal ignored.");
return;
}
if (fill.asset !== yesTokenId || fill.side !== "BUY") {
console.log("Signal does not match the configured YES-side filter.");
return;
}
if (fill.price > maximumPrice) {
console.log("Signal price is above MAX_PRICE.");
return;
}
const currentAskText = await publicClient.fetchPrice({
tokenId: yesTokenId,
side: OrderSide.BUY,
});
const currentAsk = Number(currentAskText);
console.log("Current quote", {
triggerPrice: fill.price,
currentAsk,
maximumPrice,
maximumSpend,
});
if (!Number.isFinite(currentAsk) || currentAsk > maximumPrice) {
console.log("Current ask is no longer acceptable. No order submitted.");
return;
}
if (!liveTrading) {
console.log("DRY RUN: the signal passed all market checks.");
return;
}
const geoResponse = await fetch("https://polymarket.com/api/geoblock");
if (!geoResponse.ok) {
throw new Error(`Geoblock check failed with HTTP ${geoResponse.status}.`);
}
const geo = (await geoResponse.json()) as GeoblockResponse;
if (geo.blocked) {
console.log(`Trading blocked for ${geo.country}-${geo.region}. No order submitted.`);
return;
}
const secureClient = await createSecureClient({
wallet: required("POLYMARKET_WALLET_ADDRESS"),
signer: privateKey(required("POLYMARKET_PRIVATE_KEY")),
});
const estimatedPrice = await secureClient.estimateMarketPrice({
tokenId: yesTokenId,
side: OrderSide.BUY,
amount: maximumSpendText,
orderType: OrderType.FAK,
});
if (estimatedPrice > maximumPrice) {
console.log("Estimated execution price exceeds MAX_PRICE. No order submitted.");
return;
}
const confirmationText = `BUY YES ${maximumSpendText}`;
const readline = createInterface({ input, output });
const answer = await readline.question(
`Type ${confirmationText} to submit one live order: `,
);
readline.close();
if (answer.trim() !== confirmationText) {
console.log("Confirmation did not match. No order submitted.");
return;
}
tradeAttempted = true;
const response = await secureClient.placeMarketOrder({
tokenId: yesTokenId,
side: OrderSide.BUY,
amount: maximumSpendText,
maxSpend: maximumSpendText,
maxPrice: maximumPrice,
orderType: OrderType.FAK,
});
if (!response.ok) {
console.error("Order rejected", {
code: response.code,
message: response.message,
});
return;
}
console.log("Order accepted", {
orderId: response.orderId,
status: response.status,
tradeIds: response.tradeIds,
transactionHashes: response.transactionsHashes,
});
if (response.tradeIds.length > 0) {
const hashes = await secureClient.waitForOrderFillSettlement(response);
console.log("Settlement hashes", hashes);
}
}
const server = createServer(async (request, response) => {
try {
if (request.method === "GET" && request.url === "/health") {
sendJson(response, 200, {
ok: true,
market: market.question,
liveTrading,
});
return;
}
if (request.method !== "POST" || request.url !== "/goldsky") {
sendJson(response, 404, { error: "Not found" });
return;
}
if (request.headers.authorization !== `Bearer ${webhookToken}`) {
sendJson(response, 401, { error: "Unauthorized" });
return;
}
const body = await readJson(request);
if (!isGoldskyFill(body)) {
sendJson(response, 400, { error: "Invalid Goldsky fill payload" });
return;
}
if (seenEventIds.has(body.id)) {
console.log(`Duplicate ignored: ${body.id}`);
sendJson(response, 200, { duplicate: true });
return;
}
seenEventIds.add(body.id);
sendJson(response, 202, { accepted: true });
processingQueue = processingQueue
.then(() => processSignal(body))
.catch((error: unknown) => {
console.error("Signal processing failed", error);
});
} catch (error) {
console.error("Webhook request failed", error);
sendJson(response, 500, { error: "Internal server error" });
}
});
server.listen(port, "127.0.0.1", () => {
console.log(`Listening on http://127.0.0.1:${port}`);
});
How do the guards work?
- The bearer-token comparison rejects requests that lack the value stored in WEBHOOK_TOKEN.
- The request parser limits each body to 100_000 bytes before parsing its JSON.
- The seenEventIds set records each accepted ID before returning 202.
- The processingQueue runs accepted signals in order while tradeAttempted protects the live path.
- Save src/server.ts.
- Return to the terminal running the earlier receiver.
- Press Ctrl+C to stop the earlier receiver.
- Start the guarded receiver by running this command:
npm run dev
What does this command start?
The dev script runs the updated receiver through tsx. The server loads your existing local configuration before listening for requests.
You should see the market summary followed by Listening on http://127.0.0.1:3000.
Receiver fails to restart?
Compare src/server.ts with the complete version above. A missing import or brace prevents the server from starting.
Check that your existing .env still contains the market settings from the previous step.
Help me compare my receiver with the guarded version.
Publish the protected endpoint
Cloudflare Tunnel gives your local receiver a temporary HTTPS address. A Quick Tunnel generates a random trycloudflare.com subdomain without requiring a Cloudflare account.
Publishing a local endpoint can feel risky. The receiver checks the bearer token before accepting a payload.
- Keep the guarded receiver running in its current terminal.
- Create a second terminal from Cursor's terminal panel.
- Install Cloudflare Tunnel through Homebrew by running this command:
brew install cloudflared
What does this command install?
Homebrew installs the cloudflared command used to create the temporary tunnel. The installation can take a few minutes while Homebrew downloads the package.
- Wait for Homebrew to finish the installation.
- Confirm that the terminal returns to its prompt without an installation error.
Cloudflare Tunnel did not install?
Check that Homebrew completed its update before the installation attempt. Rerun the install after any active Homebrew process finishes.
Help me diagnose my Cloudflare Tunnel installation.
The next command stays active while the tunnel is running. It prints the public address after connecting, so keep this terminal open.
- Start a Quick Tunnel to the local receiver by running this command:
cloudflared tunnel --url http://localhost:3000
What does this tunnel do?
The command forwards traffic from a temporary HTTPS address to your receiver on port 3000. The tunnel stops when this process ends.
- Find the HTTPS address ending in .trycloudflare.com in the tunnel output.
- Copy the HTTPS address into your current Quick Tunnel URL.
You now have a temporary public route into the protected receiver running on your Mac.
No public URL in the output?
Keep the local receiver running before starting the tunnel. Stop any stalled tunnel attempt with Ctrl+C before trying again.
Help me get my Quick Tunnel running.
Replay the duplicate event publicly
The test sender already includes your bearer token. Pointing it at the public route tests authorization, acknowledgement, and duplicate suppression through the tunnel.
- In Cursor's Explorer sidebar, select the existing .env file.
- Find the TEST_WEBHOOK_URL setting.
- Replace its current value with your current Quick Tunnel URL followed by /goldsky.
- Keep LIVE_TRADING set to false.
- Save .env.
- Create a third terminal from Cursor's terminal panel.
Before you run the replay, do you expect the second delivery to enter processSignal()?
- Send the duplicate event through the public endpoint by running this command:
npm run send-test
What does this replay prove?
The sender posts one event ID twice with the bearer token. The first request receives a fast acceptance before its market checks continue.
The second request finds the ID in seenEventIds. It receives a successful duplicate acknowledgement without entering the processing queue.
In the sender terminal, you should see one 202 response followed by one 200 response. In the receiver terminal, you should see one Signal received log followed by one Duplicate ignored log.
That duplicate path is now contained. One public event reaches the guarded dry-run path once.
Public duplicate test failed?
Confirm that the tunnel process is still running. A new Quick Tunnel process creates a different public URL.
Check that TEST_WEBHOOK_URL ends with /goldsky. Confirm that the sender and receiver load the same WEBHOOK_TOKEN value.
Help me debug the public duplicate replay.
Your public receiver now authenticates requests, acknowledges valid events quickly, and suppresses duplicate IDs. Next, Goldsky takes over the live on-chain signal stream.
Deploy the Goldsky Pipeline
Your public webhook now accepts authenticated events. It also ignores repeat event IDs.
The missing piece is a live source of relevant fills. Goldsky Turbo Pipelines will filter Polymarket V2 data before its webhook sink sends each matching row to your Mac.
Why Goldsky Turbo Pipelines?
A direct market connection could provide a live feed. Goldsky adds managed filtering through SQL.
Goldsky also manages webhook delivery plus pipeline checkpoints. Those features make the fill stream easier to operate after deployment.
In This Step, Get Ready To:
- Prepare the Goldsky command-line tools.
- Create an authenticated webhook secret.
- Deploy the filtered pipeline.
Install Goldsky Tools
The Goldsky CLI manages pipelines from your terminal. Its Turbo extension adds the pipeline commands used in this project.
- Install the Goldsky CLI plus its Turbo extension by running these commands:
curl https://goldsky.com | sh
curl https://install-turbo.goldsky.com | sh
What Do These Commands Install?
- The first installer adds the main Goldsky CLI.
- The second installer adds the Turbo pipeline extension.
Both installers should finish without reporting a failure.
Did an Installer Fail?
Confirm your Mac has an internet connection. Rerun only the installer that failed.
If the terminal cannot find Goldsky after installation, restart that terminal before trying again.
Ask for help with the failed installation.
A Goldsky project owns the pipeline plus its delivery secret. The login command connects your local CLI to that project.
The login flow continues in your browser. Keep the terminal open while you complete the authorization.
- Start the Goldsky sign-in flow by running this command:
goldsky login
What Does This Command Do?
This command authenticates the CLI to the Goldsky project you select in the browser. Later commands use that project for the secret plus pipeline deployment.
- Complete the authorization flow in your browser.
- Return to the terminal after authorization succeeds.
- Verify the Turbo extension against your signed-in project by running:
goldsky turbo list
What Does This Check Prove?
This command asks the Turbo extension for pipelines in your current project. An empty list still proves that the extension can reach Goldsky.
You should see the pipeline list without an authentication failure.
Cannot List Your Pipelines?
Repeat the login flow if the browser authorization expired. Confirm you selected the Goldsky project you intend to use.
Ask for help with Goldsky authentication.
The pipeline needs the same bearer value that protects your local endpoint. This credential gives Goldsky access to the webhook only.
Handling a webhook credential can feel sensitive. Your Polymarket private key stays inside the ignored local .env file.
- Copy only the value after WEBHOOK_TOKEN= from the local .env file.
- Start the Goldsky secret setup by running this command:
goldsky secret create
How Does the Secret Work?
An httpauth secret adds a configured HTTP header to every webhook request. Your server accepts the request when that header matches its local bearer-token check.
- Enter POLYMARKET_BOT_WEBHOOK as the secret name.
- Select httpauth as the secret type.
- Enter Authorization as the header name.
- Paste Bearer followed immediately by the copied webhook token as the header value.
- Complete the secret creation flow.
Good work. Goldsky can now prove that each delivered request belongs to your pipeline.
Was the Secret Rejected?
Confirm the secret name is POLYMARKET_BOT_WEBHOOK. Check that the header value begins with Bearer before the token.
Ask for help without sharing the token.
Configure Pipeline YAML
The pipeline has three stages. Its source reads fills before its transform filters them.
The placeholder mapping is a little fiddly because one value keeps quotes while another remains numeric. The webhook URL must include the final /goldsky path.
Map the Pipeline Values
- The 'YOUR_YES_TOKEN_ID' placeholder maps to the YES_TOKEN_ID value from .env. Keep the surrounding single quotes.
- The 0.50 threshold maps to the numeric MAX_PRICE value from .env. Keep this value unquoted.
- The https://YOUR-QUICK-TUNNEL.trycloudflare.com/goldsky placeholder maps to the current TEST_WEBHOOK_URL value from .env.
- Switch back to the goldsky-polymarket-bot workspace in Cursor.
- Select goldsky-pipeline.yaml in the Explorer sidebar.
- Replace all three template values using the mapping above.
- Save goldsky-pipeline.yaml.
✔️ Awesome, I've got everything!
- Confirm goldsky-pipeline.yaml is saved in Cursor.
ⓧ I'd like to double check the full code
name: goldsky-polymarket-trigger
resource_size: s
sources:
order_filled:
type: dataset
dataset_name: polymarket.order_filled
version: 2.0.0
start_at: latest
transforms:
matching_fills:
type: sql
primary_key: id
sql: |
SELECT
id,
block_number,
block_timestamp,
transaction_hash,
user_id,
asset,
side,
price,
amount_usdc,
amount_shares
FROM order_filled
WHERE asset = 'YOUR_YES_TOKEN_ID'
AND side = 'BUY'
AND price <= 0.50
sinks:
local_bot:
type: webhook
from: matching_fills
url: https://YOUR-QUICK-TUNNEL.trycloudflare.com/goldsky
secret_name: POLYMARKET_BOT_WEBHOOK
one_row_per_request: true
How to Compare This File
Use this template to compare the structure. Your saved file should contain your actual token ID plus price threshold.
Your saved sink URL should contain the current Quick Tunnel address. The remaining names plus settings should match this reference exactly.
Validate, Deploy, and Inspect
Validation catches malformed YAML plus basic SQL problems before Goldsky starts the pipeline. This gives you a fast configuration check.
Before you validate, do you expect Goldsky to accept all three pipeline stages?
- Start one additional terminal in Cursor without stopping the local bot or Quick Tunnel.
- Validate the saved pipeline configuration by running:
goldsky turbo validate goldsky-pipeline.yaml
What Does Validation Check?
Goldsky checks the YAML structure plus required fields. It also checks node references plus basic SQL syntax.
You should see validation finish successfully without configuration errors.
Did Validation Fail?
Check the indentation around sources, transforms, plus sinks. YAML uses spaces to define nesting.
Confirm the SQL token value still has single quotes. Confirm the price threshold has no quotes.
Ask for help with the validation output.
Deployment starts metered Starter usage. The tutorial fits within the one-time $100 credit. Goldsky pauses resources when that credit reaches zero. It does not charge unless you upgrade.
- Deploy the validated pipeline by running:
goldsky turbo apply goldsky-pipeline.yaml
What Does Apply Do?
This command validates the file before creating or updating goldsky-polymarket-trigger. Goldsky then starts the deployment.
The source begins at latest. That checkpoint starts the stream from new data instead of replaying the entire dataset.
You should see the CLI report a successful deployment.
Did Deployment Fail?
Rerun validation to separate a configuration problem from a deployment problem. Confirm your CLI is still signed in to the intended Goldsky project.
Ask for help with the deployment output.
Inspection lets you watch the source plus filtered transform together. It proves which rows reach the authenticated sink.
Before you inspect the stream, which node should show every new fill before the filter is applied?
- Inspect the source plus transform nodes by running:
goldsky turbo inspect goldsky-polymarket-trigger -n order_filled,matching_fills
What Does Inspection Show?
- The order_filled node reads polymarket.order_filled version 2.0.0 from the preserved source checkpoint.
- The matching_fills node keeps the configured YES token at or below MAX_PRICE.
- The local_bot sink posts one matching JSON row per authenticated request.
You should see live source data as new fills arrive. Matching rows appear only when they satisfy your token plus price filter.
A quiet matching_fills node can be valid when no recent fill meets the filter. Activity in order_filled still proves the source is streaming.
You now have a running event pipeline that turns new on-chain fills into authenticated signals for your local bot.
Your guarded receiver now has a live signal source. Next, you will use its safety checks for one tightly capped order.
Execute a Guarded Live Order
Your authenticated Goldsky Turbo pipeline now turns qualifying Polymarket YES fills into local signals. The bot already rejects duplicate event IDs before they can enter the trading path.
Each fill records an execution from the past. Current liquidity can move before the signal reaches your Mac, so this step makes a fresh quote the basis for your guarded decision.
Why re-quote the market?
An on-chain fill is evidence that a trade occurred. The bot treats that fill as a signal to fetch the current BUY ask.
This fresh quote protects your maximum price from a stale trigger. The final decision reflects the market you can trade now.
In this step, get ready to:
- Review the geographic check, execution estimate, spending cap, typed confirmation, and one-trade lock.
- Run one final dry test through the public webhook.
- Authorize one live attempt or observe a safety rejection before submission.
Review the live-trading guardrails
The bot uses a Fill and Kill order for its single attempt. Its guard chain decides whether the order path can reach your signer.
- Switch back to src/server.ts in Cursor.
- Press Cmd+F (macOS) or Ctrl+F (Windows) to open Find.
- Search for async function processSignal(fill: GoldskyFill): Promise<void>.
How does the guard chain work?
- The token check rejects fills for a different outcome.
- The side check rejects signals outside the configured YES BUY filter.
- The trigger-price check enforces MAX_PRICE before a fresh quote is requested.
- The current-price check fetches the executable BUY ask before live mode can continue.
- The geographic check calls https://polymarket.com/api/geoblock before the secure client is created.
- The execution estimate checks current order-book depth against MAX_PRICE.
- The order uses MAX_SPEND for both amount and maxSpend.
- The exact typed phrase provides manual approval for the one live attempt.
- The tradeAttempted lock activates before order placement.
The complete server file is available below for a line-by-line comparison.
✔️ Awesome, I've got everything!
Your existing src/server.ts already contains the complete guarded execution path.
ⓧ I'd like to double check the full code
import { Buffer } from "node:buffer";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { stdin as input, stdout as output } from "node:process";
import { createInterface } from "node:readline/promises";
import {
createPublicClient,
createSecureClient,
OrderSide,
OrderType,
} from "@polymarket/client";
import { privateKey } from "@polymarket/client/viem";
process.loadEnvFile();
type GoldskyFill = {
id: string;
block_number: number;
block_timestamp: number;
transaction_hash: string;
user_id: string;
asset: string;
side: string;
price: number;
amount_usdc: number;
amount_shares: number;
};
type GeoblockResponse = {
blocked: boolean;
ip: string;
country: string;
region: string;
};
function required(name: string): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`Missing ${name} in .env`);
}
return value;
}
function numberSetting(name: string, minimum: number, maximum: number): number {
const value = Number(required(name));
if (!Number.isFinite(value) || value < minimum || value > maximum) {
throw new Error(`${name} must be between ${minimum} and ${maximum}.`);
}
return value;
}
function sendJson(response: ServerResponse, status: number, body: unknown): void {
response.writeHead(status, { "Content-Type": "application/json" });
response.end(JSON.stringify(body));
}
async function readJson(request: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
let totalBytes = 0;
for await (const chunk of request) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
totalBytes += buffer.length;
if (totalBytes > 100_000) {
throw new Error("Request body is too large.");
}
chunks.push(buffer);
}
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
function isGoldskyFill(value: unknown): value is GoldskyFill {
if (typeof value !== "object" || value === null) {
return false;
}
const fill = value as Partial<GoldskyFill>;
return (
typeof fill.id === "string" &&
typeof fill.asset === "string" &&
typeof fill.side === "string" &&
typeof fill.price === "number" &&
typeof fill.amount_usdc === "number" &&
typeof fill.amount_shares === "number"
);
}
const marketUrl = required("MARKET_URL");
const configuredYesTokenId = required("YES_TOKEN_ID");
const webhookToken = required("WEBHOOK_TOKEN");
const maximumPrice = numberSetting("MAX_PRICE", 0.0001, 1);
const maximumSpend = numberSetting("MAX_SPEND", 1, 10);
const maximumSpendText = String(maximumSpend);
const port = numberSetting("PORT", 1, 65535);
const liveTrading = process.env.LIVE_TRADING === "true";
const publicClient = createPublicClient();
const market = await publicClient.fetchMarket({ url: marketUrl });
const yesTokenId = market.outcomes.yes.tokenId;
if (!yesTokenId) {
throw new Error("The selected market does not expose a YES token ID.");
}
if (yesTokenId !== configuredYesTokenId) {
throw new Error("YES_TOKEN_ID does not match the selected market URL.");
}
if (!market.state.active || market.state.closed || !market.state.acceptingOrders) {
throw new Error("The selected market is not currently accepting orders.");
}
const startupAsk = await publicClient.fetchPrice({
tokenId: yesTokenId,
side: OrderSide.BUY,
});
console.log({
question: market.question,
yesTokenId,
startupAsk,
maximumPrice,
maximumSpend,
liveTrading,
});
const seenEventIds = new Set<string>();
let tradeAttempted = false;
let processingQueue = Promise.resolve();
async function processSignal(fill: GoldskyFill): Promise<void> {
console.log("Signal received", {
id: fill.id,
side: fill.side,
triggerPrice: fill.price,
amountUsdc: fill.amount_usdc,
});
if (tradeAttempted) {
console.log("One-trade lock active. Signal ignored.");
return;
}
if (fill.asset !== yesTokenId || fill.side !== "BUY") {
console.log("Signal does not match the configured YES-side filter.");
return;
}
if (fill.price > maximumPrice) {
console.log("Signal price is above MAX_PRICE.");
return;
}
const currentAskText = await publicClient.fetchPrice({
tokenId: yesTokenId,
side: OrderSide.BUY,
});
const currentAsk = Number(currentAskText);
console.log("Current quote", {
triggerPrice: fill.price,
currentAsk,
maximumPrice,
maximumSpend,
});
if (!Number.isFinite(currentAsk) || currentAsk > maximumPrice) {
console.log("Current ask is no longer acceptable. No order submitted.");
return;
}
if (!liveTrading) {
console.log("DRY RUN: the signal passed all market checks.");
return;
}
const geoResponse = await fetch("https://polymarket.com/api/geoblock");
if (!geoResponse.ok) {
throw new Error(`Geoblock check failed with HTTP ${geoResponse.status}.`);
}
const geo = (await geoResponse.json()) as GeoblockResponse;
if (geo.blocked) {
console.log(`Trading blocked for ${geo.country}-${geo.region}. No order submitted.`);
return;
}
const secureClient = await createSecureClient({
wallet: required("POLYMARKET_WALLET_ADDRESS"),
signer: privateKey(required("POLYMARKET_PRIVATE_KEY")),
});
const estimatedPrice = await secureClient.estimateMarketPrice({
tokenId: yesTokenId,
side: OrderSide.BUY,
amount: maximumSpendText,
orderType: OrderType.FAK,
});
if (estimatedPrice > maximumPrice) {
console.log("Estimated execution price exceeds MAX_PRICE. No order submitted.");
return;
}
const confirmationText = `BUY YES ${maximumSpendText}`;
const readline = createInterface({ input, output });
const answer = await readline.question(
`Type ${confirmationText} to submit one live order: `,
);
readline.close();
if (answer.trim() !== confirmationText) {
console.log("Confirmation did not match. No order submitted.");
return;
}
tradeAttempted = true;
const response = await secureClient.placeMarketOrder({
tokenId: yesTokenId,
side: OrderSide.BUY,
amount: maximumSpendText,
maxSpend: maximumSpendText,
maxPrice: maximumPrice,
orderType: OrderType.FAK,
});
if (!response.ok) {
console.error("Order rejected", {
code: response.code,
message: response.message,
});
return;
}
console.log("Order accepted", {
orderId: response.orderId,
status: response.status,
tradeIds: response.tradeIds,
transactionHashes: response.transactionsHashes,
});
if (response.tradeIds.length > 0) {
const hashes = await secureClient.waitForOrderFillSettlement(response);
console.log("Settlement hashes", hashes);
}
}
const server = createServer(async (request, response) => {
try {
if (request.method === "GET" && request.url === "/health") {
sendJson(response, 200, {
ok: true,
market: market.question,
liveTrading,
});
return;
}
if (request.method !== "POST" || request.url !== "/goldsky") {
sendJson(response, 404, { error: "Not found" });
return;
}
if (request.headers.authorization !== `Bearer ${webhookToken}`) {
sendJson(response, 401, { error: "Unauthorized" });
return;
}
const body = await readJson(request);
if (!isGoldskyFill(body)) {
sendJson(response, 400, { error: "Invalid Goldsky fill payload" });
return;
}
if (seenEventIds.has(body.id)) {
console.log(`Duplicate ignored: ${body.id}`);
sendJson(response, 200, { duplicate: true });
return;
}
seenEventIds.add(body.id);
sendJson(response, 202, { accepted: true });
processingQueue = processingQueue
.then(() => processSignal(body))
.catch((error: unknown) => {
console.error("Signal processing failed", error);
});
} catch (error) {
console.error("Webhook request failed", error);
sendJson(response, 500, { error: "Internal server error" });
}
});
server.listen(port, "127.0.0.1", () => {
console.log(`Listening on http://127.0.0.1:${port}`);
});
This reference matches the final guarded server implementation. The signer remains local because .env is excluded by .gitignore.
Run one final dry check
Dry mode exercises the token, side, trigger-price, and current-ask checks without constructing the secure client. It gives you one last view of the decision path before money is involved.
- Keep the existing bot terminal running with LIVE_TRADING=false.
- Switch to a spare terminal in Cursor.
Before you run the test, do you expect the second delivery to enter the trading path?
- Send a fresh duplicate test through the current Quick Tunnel by running:
npm run send-test
What should I see?
The sender receives two successful HTTP responses. The first event enters the queue while the repeated event receives an acknowledgement as a duplicate.
- Return to the terminal running the bot.
- Confirm one event reaches the current-price check.
- Confirm the repeated event logs Duplicate ignored.
An acceptable current ask produces the DRY RUN message. A higher current ask produces a safety rejection with no order submission.
No fresh signal in the bot?
Confirm the Quick Tunnel process is still running. Check that TEST_WEBHOOK_URL still contains its current public /goldsky URL.
If the request still does not arrive, help me trace the test event through the tunnel and webhook.
Authorize one guarded live attempt
This is the only action in the project that can spend real pUSD. Your existing MAX_SPEND ceiling limits the attempt to the amount you chose.
Your signer credentials stay inside the ignored local .env file. Keep that file off-screen whenever you capture or share your result.
- Press Ctrl+C in the bot terminal to stop dry mode.
- Switch back to .env in Cursor.
- Confirm MAX_SPEND remains no greater than 10.
Your configured spending cap is the hard ceiling for both order notional and fees.
- Change LIVE_TRADING=false to LIVE_TRADING=true.
- Save .env.
- Restart the guarded bot by running:
npm run dev
What should I see?
The startup summary shows your market question, YES token, current ask, maximum price, and maximum spend. It also shows liveTrading set to true.
The listener remains on http://127.0.0.1:3000 behind the running Quick Tunnel.
Bot does not restart?
Confirm the previous bot process stopped before the restart. Check that the startup summary keeps MAX_SPEND within the allowed range.
For help without sharing credentials, help me diagnose why the guarded bot does not start in live mode.
Before you send the fresh event, which guard do you expect to decide the outcome?
- Switch to the spare terminal from your dry test.
- Send one fresh event through the public webhook by running:
npm run send-test
What does this final test prove?
The first request receives a fast acknowledgement before processing continues. The repeated request proves that duplicate suppression remains active during live mode.
The accepted event must pass the current ask, geographic eligibility, and estimated execution-price checks before the confirmation prompt can appear.
- Return to the terminal running the bot.
- Type the exact confirmation phrase shown if the prompt appears.
- Press Enter to authorize the attempt if you entered the phrase.
A failed safety check produces a clear rejection before any order is submitted. The message identifies whether the current ask, geographic eligibility, or estimated execution price stopped the attempt.
A successful attempt logs the order ID, status, trade IDs, and transaction hashes. Settlement hashes follow when the order matches immediately.
That is the critical path complete. Your signal reached a real order decision without bypassing the limits you set.
The final live attempt is complete. Returning the bot to dry mode prevents later Goldsky deliveries from reaching another live confirmation.
- Switch back to .env in Cursor.
The file still shows LIVE_TRADING=true from the guarded final run.
- Change LIVE_TRADING=true back to LIVE_TRADING=false.
- Save .env.
- Return to the terminal running the bot.
- Press Ctrl+C to stop the live process.
- Restart the bot in dry mode by running:
npm run dev
What should I see?
The startup summary now shows liveTrading set to false.
The bot stays available for authenticated Goldsky signals while future events remain unable to submit another live order.
Secret mission
Prove Checkpoint-Safe Recovery
Pause the running Goldsky Turbo pipeline without clearing its state. Resume the same deployment and prove that its Polymarket source continues from the preserved checkpoint while local idempotency still guards against duplicate deliveries.
Clean Up Your Resources
Clean Up Your Resources
Your running Goldsky Turbo pipeline keeps drawing from the one-time Starter credit. Choose whether to keep the system active, pause it for later, or delete the resources.
Cost warning
Goldsky Starter includes a one-time $100 credit. Usage does not trigger an automatic charge unless the account upgrades to Scale.
A live Polymarket run can spend up to 10 pUSD, including fees.
Deleting the project resources leaves any completed trade or resulting position untouched.
Resources you used:
- The Goldsky Turbo pipeline goldsky-polymarket-trigger with preserved checkpoint state.
- The Goldsky httpauth secret named POLYMARKET_BOT_WEBHOOK.
- A local webhook bot plus a temporary Cloudflare Quick Tunnel.
- The local TypeScript project named goldsky-polymarket-bot with signer-sensitive configuration in .env.
- At most one guarded Polymarket order plus any resulting position.
Keep everything running
No action is needed. Choose this if you plan to keep testing live signals or refining the safety controls.
- Leave the local bot process running in its terminal tab.
- Leave the Cloudflare Quick Tunnel process running in its terminal tab.
- Keep goldsky-polymarket-trigger running while remembering that it continues consuming Starter credit.
- Keep .env ignored by Git so the signer private key stays out of commits.
- Review any resulting Polymarket position separately from the project infrastructure.
Pause - I'll come back to this later
Shut down active processes to stop local traffic while keeping the project files plus the pipeline checkpoint for later.
- Pause the running pipeline by running this command:
goldsky turbo pause goldsky-polymarket-trigger
What Does Pausing Preserve?
Pausing stops the pipeline replicas while preserving checkpoint state. The same deployment can continue from that checkpoint when you resume it.
- Stop the local bot by pressing Ctrl+C in its terminal tab.
- Stop the Cloudflare Quick Tunnel by pressing Ctrl+C in its terminal tab.
- Retain the goldsky-polymarket-bot folder for your next session.
A new Quick Tunnel can produce a different public address. Update the webhook configuration before restoring delivery.
- Start the local bot again with the same command from Step 3.
- Start the Cloudflare Quick Tunnel again with the same command from Step 3.
- Record the new public /goldsky URL as your new Quick Tunnel /goldsky URL.
- Replace TEST_WEBHOOK_URL in .env with your new Quick Tunnel /goldsky URL.
- Replace the webhook url in goldsky-pipeline.yaml with your new Quick Tunnel /goldsky URL.
- Update the paused deployment with the pipeline update flow from Step 4.
- Restore the paused pipeline by running this command:
goldsky turbo resume goldsky-polymarket-trigger
What Does Resuming Restore?
Resuming restores the running state without clearing the preserved checkpoint. Local event-ID deduplication still protects the bot from retried webhook deliveries.
Delete - I don't want to use this again
This cleanup permanently removes the local and Goldsky setup, so use it only when you are finished with the recovery demo. Any completed Polymarket trade remains in your account.
- Delete the pipeline by running this command:
goldsky turbo delete goldsky-polymarket-trigger
What Does Deleting Remove?
This removes the deployed pipeline plus its checkpoint state by default. Future streaming requires a fresh deployment.
- Delete the Goldsky webhook secret named POLYMARKET_BOT_WEBHOOK from the Goldsky project that owned the pipeline.
- Stop the local bot by pressing Ctrl+C in its terminal tab.
- Stop the Cloudflare Quick Tunnel by pressing Ctrl+C in its terminal tab.
- Move the goldsky-polymarket-bot folder to the Trash in Finder.
Removing the project folder also removes the uncommitted .env file containing the local wallet address plus signer private key.
- Empty the Trash to finish removing the local project.
- Review whether the guarded FAK buy created a position in Polymarket.
- Handle any resulting position separately according to your own trading decision.
Nice Work!
Nice Work!
You did it! Your local TypeScript bot now turns filtered Polymarket on-chain fills into guarded trade decisions. The bot either submits one capped FAK order or rejects it with an observable safety reason.
You've learned how to:
- Build a local webhook receiver for one market. It identifies the YES token. It re-quotes the current BUY price before any trade decision.
- Stream filtered fill events through Goldsky Turbo Pipelines. The webhook authenticates each delivery. Event-ID idempotency suppresses retries before they reach the trading path.
- Enforce guarded order execution through a geographic eligibility check. The order uses a maximum price. Its all-in spend stays at or below 10 pUSD. Exact typed confirmation is required before signing. The result is one submitted or safely rejected FAK YES buy with a visible reason.
- Secret Mission: Paused and resumed the goldsky-polymarket-trigger pipeline without clearing state. Confirmed that the order_filled stream continued from its preserved checkpoint. Matched checkpoint recovery with local seenEventIds protection against duplicate webhook deliveries.
Ready to quiz yourself?