Webhooks
Send trading signals to UTM via webhooks from any source.
Overview
Webhooks allow external systems to send trading signals to UTM. This is useful for:
- Custom trading bots
- Third-party signal providers
- Automated scripts
- Any system that can make HTTP requests
Webhook URL
Send POST requests to:
https://api.universaltrademanager.com/api/v1/signals
Authentication
Include your API key in the request header:
X-API-Key: your-api-key-here
Or as a query parameter:
?apiKey=your-api-key-here
Use the header method for better security. Query parameters may be logged.
Request Format
Immediate Signal
Process and place the order straight away:
POST /api/v1/signals
Content-Type: application/json
X-API-Key: your-api-key
{
"symbol": "AAPL",
"action": "openLong",
"accountId": "your-account-id",
"quantity": 100,
"executeMode": "immediate"
}
Scheduled Signal
Queue the signal for processing at a specific time. Both scheduledProcessAt (ISO datetime, no Z suffix) and timezone (IANA identifier) are required, and the time must be in the future.
POST /api/v1/signals
Content-Type: application/json
X-API-Key: your-api-key
{
"symbol": "AAPL",
"action": "openLong",
"accountId": "your-account-id",
"quantity": 100,
"executeMode": "scheduled",
"scheduledProcessAt": "2026-05-15T09:30:00",
"timezone": "America/New_York"
}
A scheduled signal returns 202 Accepted with status: "queued" and scheduledProcessAt set; the queue worker processes it at the requested time.
Limit Order with Timed Exit
POST /api/v1/signals
Content-Type: application/json
X-API-Key: your-api-key
{
"symbol": "AAPL",
"action": "openLong",
"accountId": "your-account-id",
"quantity": 100,
"executeMode": "immediate",
"orderType": "limit",
"limitPrice": 150.00,
"exitTriggerType": "minutesAfterEntry",
"exitTriggerMinutes": 30,
"exitOrderType": "market"
}
Request Fields
Required Fields
| Field | Type | Description |
|---|---|---|
symbol | string | Stock symbol (e.g., "AAPL"), 1-20 chars |
action | string | Signal action (see below) |
accountId | UUID | Target account ID |
quantity | number | Order quantity (positive) |
executeMode | string | immediate or scheduled |
Optional Fields
| Field | Type | Default | Description |
|---|---|---|---|
scheduledProcessAt | ISO datetime | - | Required when executeMode is scheduled |
timezone | string | - | IANA timezone, required when scheduledProcessAt or validUntil is set |
validUntil | ISO datetime | - | Signal expiration (requires timezone) |
quantityType | string | fixed | fixed, percentEquity, dollarAmount |
orderType | string | market | market, limit, stop, stopLimit |
limitPrice | number | - | Required for limit and stopLimit orders |
stopPrice | number | - | Required for stop and stopLimit orders |
timeInForce | string | day | day, gtc, opg, cls, ioc, fok, gtd (gtd requires expiresAt) |
expiresAt | ISO datetime | - | Broker order expiry. Required when timeInForce is gtd, within 90 days |
extendedHours | boolean | false | Allow pre/post-market trading |
strategyId | UUID | - | Associated strategy |
sourceId | string | - | Idempotency key (scoped per account) |
replaceExisting | boolean | false | Replace existing signal with same sourceId |
metadata | object | - | Custom key-value pairs |
exitTriggerType | string | - | minutesAfterEntry, minutesBeforeClose, atClockTime, or immediate |
exitTriggerMinutes | integer | - | Minutes count (0 to 60). Required for minutesAfterEntry and minutesBeforeClose |
exitTriggerTime | string | - | HH:MM (24-hour). Required for atClockTime |
exitOrderType | string | - | market, limit, stop, stopLimit, or moc |
exitLimitPrice | number | - | Limit price for the exit order. Required for limit and stopLimit |
exitTimeInForce | string | - | day or cls. Required as cls when exitOrderType is moc |
Signal Actions
| Action | Description |
|---|---|
openLong | Open a long position (buy) |
openShort | Open a short position (sell short) |
closeLong | Close a long position (sell) |
closeShort | Close a short position (buy to cover) |
Quantity Types
| Type | Description | Example |
|---|---|---|
fixed | Exact number of shares | 100 = 100 shares |
percentEquity | Percentage of account equity | 10 = 10% of equity |
dollarAmount | Dollar value to invest | 5000 = $5,000 worth |
Response Format
Success - Immediate (201)
{
"success": true,
"message": "Signal processed successfully",
"signal": {
"id": "550e8400-e29b-41d4-a716-446655440099",
"status": "transmitted",
"orderId": "550e8400-e29b-41d4-a716-446655440098",
"executeMode": "immediate"
}
}
Success - Scheduled (202)
{
"success": true,
"message": "Signal scheduled for processing",
"signal": {
"id": "550e8400-e29b-41d4-a716-446655440099",
"status": "queued",
"scheduledAt": "2026-05-15T13:30:00.000Z",
"executeMode": "scheduled"
}
}
Error
{
"success": false,
"error": "VALIDATION_ERROR",
"message": "Invalid symbol format",
"details": [
{
"field": "symbol",
"message": "Symbol must be uppercase letters only"
}
]
}
Examples
Python
import requests
url = "https://api.universaltrademanager.com/api/v1/signals"
headers = {
"Content-Type": "application/json",
"X-API-Key": "your-api-key"
}
payload = {
"symbol": "AAPL",
"action": "openLong",
"accountId": "your-account-id",
"quantity": 100,
"executeMode": "immediate"
}
response = requests.post(url, json=payload, headers=headers)
print(response.json())
cURL
curl -X POST https://api.universaltrademanager.com/api/v1/signals \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key" \
-d '{
"symbol": "AAPL",
"action": "openLong",
"accountId": "your-account-id",
"quantity": 100,
"executeMode": "immediate"
}'
JavaScript
const response = await fetch(
"https://api.universaltrademanager.com/api/v1/signals",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": "your-api-key",
},
body: JSON.stringify({
symbol: "AAPL",
action: "openLong",
accountId: "your-account-id",
quantity: 100,
executeMode: "immediate",
}),
},
);
const data = await response.json();
console.log(data);
Idempotency and retries
Network calls fail. Cron jobs re-fire. A retry that lands twice as two real signals can double your position. UTM uses an idempotency key called sourceId so a script can re-send the same signal as many times as it needs without creating duplicates.
Why sourceId exists
When you set sourceId on a request, UTM treats it as the canonical name for that logical signal. Any subsequent request from the same account with the same sourceId returns the original signal instead of creating a new one. This makes it safe to:
- Retry after a network timeout
- Re-fire a cron job that may have already run
- Replay a webhook that was delivered twice
If you do not set sourceId, there is no dedupe. Every request creates a new signal. A retried network call will place a second order at the broker.
Dedupe scope
The lookup matches on:
- Same
accountId - Same
sourceId - Status is not
cancelledorerror
Cancelled and errored statuses are excluded on purpose. If the prior attempt did not result in a live order, a retry with the same sourceId is allowed to create a fresh signal. You do not have to mint a new key just to escape a failed attempt.
Choosing a sourceId
The key needs to be stable for the same logical signal, and unique across logical signals. A random UUID per request defeats the purpose, because each retry would generate a different key.
Good patterns:
${strategy}-${symbol}-${barTimestamp}for bar-driven strategies${runId}-${signalIndex}for batch jobs${alertId}if your upstream system already issues stable IDs
Maximum length is 255 characters.
replaceExisting
By default, a duplicate request is a no-op: the existing signal is returned with HTTP 200. Set replaceExisting: true if you want the new request to supersede the prior one. For example, when the same bar produces an updated limit price.
The outcome depends on the state of the broker order:
| Prior order state | Result of replaceExisting: true |
|---|---|
| No order yet | Prior signal cancelled, new signal created |
| Order placed, unfilled | Order cancelled at broker, prior signal cancelled, new signal created |
| Partial or full fill | Request rejected with "Cannot replace signal: associated order has X/Y filled. Cancel position manually or use a new sourceId" |
| Broker cancel fails | Logged as a warning, prior signal cancelled in UTM, new signal created. Broker side may need manual reconciliation |
The design rule: UTM will not silently override a signal that has already moved money. Once a fill exists, you must either let the original ride or place a fresh signal with a new sourceId.
Recommended pattern
- Generate a deterministic
sourceIdfrom the inputs that define the signal. - Always send it. Default
replaceExistingtofalse. - On a network error, retry the same request. The dedupe makes it safe.
- Use
replaceExisting: trueonly when intentionally superseding an unfilled order. - After a fill, never reuse the
sourceIdto force a new attempt. Mint a new one.
Rate Limits
Webhook requests are rate limited:
| Limit | Window |
|---|---|
| 100 requests | per minute |
See Rate Limits for details.
Error Codes
| Code | Description |
|---|---|
INVALID_API_KEY | API key is missing or invalid |
VALIDATION_ERROR | Request payload validation failed |
ACCOUNT_NOT_FOUND | Target account doesn't exist |
MODE_MISMATCH | Trading mode doesn't match account type |
RATE_LIMIT_EXCEEDED | Too many requests |
BROKER_ERROR | Broker rejected the order |
Testing
Use paper trading accounts to test your webhook integration:
- Create a paper trading account in UTM
- Get the account ID from the Accounts page
- Send test signals with paper mode enabled
- Verify signals appear in the Signals page
Best Practices
- Use HTTPS - Always send webhooks over HTTPS
- Handle errors - Check response codes and retry on failures
- Validate locally - Validate signal data before sending
- Log requests - Keep logs for debugging
- Use idempotency - Send a stable
sourceIdso retries are safe. See Idempotency and retries - Test first - Use paper trading before live trading