{"openapi":"3.1.0","info":{"title":"Propelr API — Post Alerts","version":"1.0.0","description":"Standalone HTTP API for Post Alerts — team watchlists, briefing activity, credit usage, and\nnotification settings.\n\nAuthenticate with your org API key (`propelr_live_` prefix) in the `Authorization: Bearer` header.\nThe organization is resolved from the key — **do not** send `organizationId` on API requests.\n\nAll routes are versioned under `/v1` (e.g. `https://propelr.app/api/v1/targets`). Future breaking\nchanges will ship under a new version prefix rather than mutating `/v1`.\n\n## How credits work\n\nPost Alerts uses a monthly **check budget** tied to your billing cycle. **1 credit = 1 check**\nof a single profile or company for new posts. There is **no cap** on how many targets you can\nadd — only running checks consumes credits. Credits reset at the start of each billing cycle\nand do not roll over.\n\n`GET /v1/credits` returns a simple budget equation for the current cycle:\n\n```\nchecksCommittedThisCycle = checksUsedThisCycle + checksPlannedRestOfCycle\nchecksRemaining          = checksAllowed - checksCommittedThisCycle\n```\n\n| Field | Meaning |\n| --- | --- |\n| `checksAllowed` | Plan allowance for this billing cycle |\n| `checksUsedThisCycle` | Checks already run (ledger since `checksCycleStartsAt`) |\n| `checksPlannedRestOfCycle` | Checks still scheduled from now until `checksResetsAt` |\n| `checksCommittedThisCycle` | Used + planned — total checks accounted for this cycle |\n| `checksRemaining` | Allowance left after used and planned (negative = overcommitted) |\n| `projectedChecksPerMonth` | Full-cycle cadence commitment at current settings (30-day formula; e.g. 5 min → 8,640/target) |\n\nCadence edits and bulk updates are rejected with `402 credits_exceeded` when they would push\n`projectedChecksPerMonth` above `checksAllowed` or `checksCommittedThisCycle` above `checksAllowed`.\nAdding targets via `POST /v1/targets` is never credit-blocked.\n\nOn-demand post fetches (`GET /v1/targets/{targetId}/posts`) consume **1 credit** per successful\nfetch. They count toward **`checksUsedThisCycle`** and do **not** add to\n**`checksPlannedRestOfCycle`**. The request is rejected with `402 credits_exceeded` when\n`checksRemaining` is less than 1 (all allowance is already used or planned).\n\n## Rate limits\n\nMost API calls are free at the HTTP layer — you are billed in credits only when a watchlist check\nruns or when an on-demand post fetch succeeds (`GET /v1/targets/{targetId}/posts`):\n\n| Scope | Limit |\n| --- | --- |\n| Authenticated requests (`/v1/targets`, `/v1/targets/{targetId}/posts`, `/v1/credits`, `/v1/settings`, `/v1/activities`, …) | 120 requests/minute per IP |\n| `GET /v1/health` (unauthenticated) | 20 requests/minute per IP |\n\nExceeding the limit returns `429 Too Many Requests` with a `Retry-After` header (seconds until\nyou can retry). Limits apply per source IP regardless of which API key or session is used, and are\nenforced independently of your credit budget.\n","contact":{"name":"Propelr Support","email":"support@propelr.app"}},"servers":[{"url":"https://propelr.app/api","description":"Production"},{"url":"https://localhost:3000/api","description":"Local Next.js development"},{"url":"http://localhost:8790","description":"Local standalone Docker service"}],"tags":[{"name":"Health","description":"Service health checks"},{"name":"Targets","description":"Watchlist profiles and companies"},{"name":"Credits","description":"Monthly credit balance and usage"},{"name":"Webhooks","description":"Outbound `POST` events Propelr sends to your configured `webhookUrl`. Every delivery is signed with\nHMAC-SHA256 — verify `X-Propelr-Signature` before trusting the JSON body. See\n`post_alerts.activity.created` for the full verification guide.\n"}],"security":[{"bearerAuth":[]}],"paths":{"/v1/health":{"get":{"operationId":"getHealth","tags":["Health"],"summary":"Health check","description":"Returns service status. No authentication required.","security":[],"responses":{"200":{"description":"Service is healthy","content":{"application/json":{"schema":{"type":"object","required":["ok","service","timestamp"],"properties":{"ok":{"type":"boolean","example":true},"service":{"type":"string","example":"post-alerts-api"},"timestamp":{"type":"string","format":"date-time"}}}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/v1/credits":{"get":{"operationId":"getCredits","tags":["Credits"],"summary":"Get current credit balance","description":"Returns the organization's check budget for the current billing cycle.\n\n### Budget equation\n\n```\nchecksCommittedThisCycle = checksUsedThisCycle + checksPlannedRestOfCycle\nchecksRemaining          = checksAllowed - checksCommittedThisCycle\n```\n\n### Response fields\n\n**checksAllowed** (integer)  \nMonthly check allowance from the org's Post Alerts plan. One credit equals one target\ncheck. Adding targets does not change this value — only checks that actually run consume\ncredits.\n\n**checksUsedThisCycle** (integer)  \nSuccessful checks and on-demand post fetches already recorded in the credit ledger during\nthe current billing cycle. Counted from checksCycleStartsAt (inclusive). Each completed\nscrape of one target or each successful `GET /v1/targets/{targetId}/posts` consumes one\ncredit.\n\n**checksPlannedRestOfCycle** (integer)  \nForward-looking count of checks still scheduled to run from the current time until\nchecksResetsAt, based on each active target's checkFrequencyMinutes. Paused and expired\ntargets are excluded. Decreases as the cycle progresses and increases when cadences are\nshortened or targets are resumed.\n\n**checksCommittedThisCycle** (integer)  \nTotal checks accounted for this cycle: checksUsedThisCycle plus checksPlannedRestOfCycle.\nRepresents everything already spent plus everything still on the calendar before reset.\nCompare against checksAllowed to see runway.\n\n**checksRemaining** (integer)  \nBudget headroom left this cycle: checksAllowed minus checksCommittedThisCycle. Positive\nmeans uncommitted allowance remains. Zero means fully allocated. Negative means\novercommitted — the org is on track to exceed its allowance before reset.\n\n**checksCycleStartsAt** (string, date-time, nullable)  \nStart of the current billing cycle. Defines the lower bound for checksUsedThisCycle\nledger queries. Sourced from Stripe current_period_start when available; null when the\ncycle start cannot be determined.\n\n**checksResetsAt** (string, date-time, nullable)  \nWhen the monthly allowance refreshes for the next billing cycle. Also the upper bound\nfor checksPlannedRestOfCycle projections. Typically matches Stripe current_period_end.\n\n**projectedChecksPerMonth** (integer)  \nSteady-state monthly burn at current target cadences, assuming a full 30-day period.\nComputed as the sum of (43,200 ÷ checkFrequencyMinutes) across every active target.\nMatches the per-target \"~X checks/mo\" estimates shown in the dashboard. Two targets at\n5-minute cadence → 17,280. Used for monthly preflight when editing cadences — distinct\nfrom checksPlannedRestOfCycle, which only counts the rest of the current cycle.\n\nAdding targets via POST /v1/targets is always allowed. Cadence edits and bulk updates\nmay return 402 credits_exceeded when they would push projectedChecksPerMonth or\nchecksCommittedThisCycle above checksAllowed.\n","responses":{"200":{"description":"Current billing-cycle credit summary. See the operation description above for field-by-field semantics.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Credits"},"example":{"checksAllowed":36000,"checksUsedThisCycle":172,"checksPlannedRestOfCycle":16146,"checksCommittedThisCycle":16318,"checksRemaining":19682,"checksCycleStartsAt":"2026-07-05T00:00:00.000Z","checksResetsAt":"2026-08-05T00:00:00.000Z","projectedChecksPerMonth":17280}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/v1/targets":{"get":{"operationId":"listTargets","tags":["Targets"],"summary":"List watchlist targets","description":"Returns a page of watchlist targets. Use `cursor` from the previous response's `nextCursor`\nto fetch the next page. Default page size is 50 (max 100).\n\nFilter by entity type with `type` (`all`, `person`, `company`).\n","parameters":[{"name":"type","in":"query","schema":{"type":"string","enum":["all","person","company"],"default":"all"},"description":"Filter to profiles (`person`), companies (`company`), or both (`all`)."},{"name":"sort","in":"query","schema":{"type":"string","enum":["recent_activity","date_added","name"],"default":"recent_activity"}},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":50},"description":"Page size (default 50, max 100)."},{"name":"cursor","in":"query","schema":{"type":"string"},"description":"Opaque cursor from a previous response's `nextCursor`. Omit for the first page."}],"responses":{"200":{"description":"Target list page","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TargetsListPage"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"post":{"operationId":"createTarget","tags":["Targets"],"summary":"Add target(s) to the watchlist","description":"Add one profile/company URL, a batch of URLs, or a lead by ID. There's no limit on the\nnumber of targets you can watch — only checking them consumes credits.\n\nProvide exactly one of: `profileUrl`, `urls`, or `leadId`.\n\nReturns **201** when a single target is created (`profileUrl` or `leadId`). Returns **200** for\nbulk `urls[]` with an aggregate result.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/CreateTargetByUrl"},{"$ref":"#/components/schemas/CreateTargetsByUrls"},{"$ref":"#/components/schemas/CreateTargetByLead"}]},"examples":{"singleUrl":{"summary":"Single LinkedIn URL","value":{"profileUrl":"https://www.linkedin.com/in/example/"}},"singleUrlWithCadence":{"summary":"Single URL with a custom check cadence","value":{"profileUrl":"https://www.linkedin.com/in/example/","checkFrequencyMinutes":10080}},"bulkUrls":{"summary":"Multiple URLs","value":{"urls":["https://www.linkedin.com/in/example/","https://www.linkedin.com/company/example/"]}},"lead":{"summary":"From Propelr lead","value":{"leadId":"550e8400-e29b-41d4-a716-446655440000"}}}}}},"responses":{"200":{"description":"Bulk add result (`urls[]` only)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkAddResult"}}}},"201":{"description":"Target created","content":{"application/json":{"schema":{"type":"object","required":["target"],"properties":{"target":{"$ref":"#/components/schemas/WatchedTarget"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"$ref":"#/components/responses/SubscriptionRequired"},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"description":"Target already on the watchlist","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorWithExisting"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/v1/targets/bulk":{"post":{"operationId":"bulkUpdateTargets","tags":["Targets"],"summary":"Bulk pause, resume, remove, or update targets","description":"Use `action: update` with at least one of `checkFrequencyMinutes` or `expiresAt` to apply\ncadence or expiration updates. Pass homogeneous values via top-level fields + `ids`, or\nper-target overrides via `targets` (string ids or `{ id, checkFrequencyMinutes?, expiresAt? }`).\n\n**Field resolution:** plain ids / string entries use top-level values, then org defaults.\nObject entries are partial patches (omitted fields unchanged). The same id in both `ids`\nand `targets` uses the `targets` object for overlapping fields.\n\nCredit preflight runs on the **entire** resolved batch before any write. On `402 credits_exceeded`,\n**zero** targets are updated (atomic reject). Adding targets via `POST /v1/targets` is never\ncredit-blocked.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["action"],"properties":{"action":{"type":"string","enum":["pause","resume","remove","update"]},"ids":{"type":"array","items":{"type":"string","format":"uuid"},"description":"Target ids for pause/resume/remove, or homogeneous update (top-level fields apply to each id)."},"targets":{"type":"array","description":"Per-target update entries — a UUID string or object with optional `checkFrequencyMinutes` / `expiresAt`. Combined with `ids`; object entries win on duplicate ids.","items":{"oneOf":[{"type":"string","format":"uuid"},{"type":"object","required":["id"],"properties":{"id":{"type":"string","format":"uuid"},"checkFrequencyMinutes":{"$ref":"#/components/schemas/CheckFrequencyMinutes"},"expiresAt":{"type":"string","format":"date-time","nullable":true}}}]}},"checkFrequencyMinutes":{"$ref":"#/components/schemas/CheckFrequencyMinutes","description":"Required for `update` when `expiresAt` is omitted."},"expiresAt":{"type":"string","format":"date-time","nullable":true,"description":"Per-target watch end time (ISO 8601). Set to `null` to clear. Required for `update` when `checkFrequencyMinutes` is omitted."}}},"example":{"action":"update","checkFrequencyMinutes":1440,"expiresAt":"2026-12-31T23:59:59.000Z","ids":["bc1b528e-d03c-4f7d-8870-e4f5b73b0eb9","a1b2c3d4-e5f6-7890-abcd-ef1234567890"],"targets":["093b0e51-3f72-470c-93e9-2f493ce3ec57",{"id":"bc1b528e-d03c-4f7d-8870-e4f5b73b0eb9","checkFrequencyMinutes":5},{"id":"f47ac10b-58cc-4372-a567-0e02b2c3d479","checkFrequencyMinutes":60,"expiresAt":"2026-09-30T23:59:59.000Z"},{"id":"6ba7b810-9dad-11d1-80b4-00c04fd430c8","expiresAt":null}]},"examples":{"comprehensiveUpdate":{"summary":"Mixed update (recommended reference)","description":"Demonstrates all `update` resolution rules in one request. Top-level `checkFrequencyMinutes` and `expiresAt` apply to plain `ids` entries and string `targets` entries. Object entries in `targets` are partial patches — only supplied fields change. When the same id appears in both `ids` and `targets`, the `targets` object wins for overlapping fields (here `bc1b528e…` gets 5 min instead of the top-level 1440).","value":{"action":"update","checkFrequencyMinutes":1440,"expiresAt":"2026-12-31T23:59:59.000Z","ids":["bc1b528e-d03c-4f7d-8870-e4f5b73b0eb9","a1b2c3d4-e5f6-7890-abcd-ef1234567890"],"targets":["093b0e51-3f72-470c-93e9-2f493ce3ec57",{"id":"bc1b528e-d03c-4f7d-8870-e4f5b73b0eb9","checkFrequencyMinutes":5},{"id":"f47ac10b-58cc-4372-a567-0e02b2c3d479","checkFrequencyMinutes":60,"expiresAt":"2026-09-30T23:59:59.000Z"},{"id":"6ba7b810-9dad-11d1-80b4-00c04fd430c8","expiresAt":null}]}},"homogeneousUpdate":{"summary":"Same cadence for every id","description":"Top-level fields apply uniformly to each id. Use when every target in the batch should share the same cadence or expiration.","value":{"action":"update","checkFrequencyMinutes":10080,"ids":["bc1b528e-d03c-4f7d-8870-e4f5b73b0eb9","093b0e51-3f72-470c-93e9-2f493ce3ec57","f47ac10b-58cc-4372-a567-0e02b2c3d479"]}},"pause":{"summary":"Pause multiple targets","value":{"action":"pause","ids":["bc1b528e-d03c-4f7d-8870-e4f5b73b0eb9","093b0e51-3f72-470c-93e9-2f493ce3ec57"]}},"resume":{"summary":"Resume paused targets","description":"Credit preflight applies — resuming may return `402 credits_exceeded` if active cadences would exceed the plan allowance or runway.","value":{"action":"resume","ids":["bc1b528e-d03c-4f7d-8870-e4f5b73b0eb9","093b0e51-3f72-470c-93e9-2f493ce3ec57"]}},"remove":{"summary":"Remove targets from the watchlist","value":{"action":"remove","ids":["bc1b528e-d03c-4f7d-8870-e4f5b73b0eb9","093b0e51-3f72-470c-93e9-2f493ce3ec57"]}},"updateExpiration":{"summary":"Set the same expiration on multiple targets","value":{"action":"update","ids":["bc1b528e-d03c-4f7d-8870-e4f5b73b0eb9","093b0e51-3f72-470c-93e9-2f493ce3ec57"],"expiresAt":"2026-12-31T23:59:59.000Z"}},"clearExpiration":{"summary":"Clear per-target expiration","value":{"action":"update","ids":["bc1b528e-d03c-4f7d-8870-e4f5b73b0eb9"],"expiresAt":null}}}}}},"responses":{"200":{"description":"Number of targets updated","content":{"application/json":{"schema":{"type":"object","required":["count"],"properties":{"count":{"type":"integer"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"oneOf":[{"$ref":"#/components/responses/SubscriptionRequired"},{"$ref":"#/components/responses/CreditsExceeded"}]},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/v1/targets/{targetId}":{"parameters":[{"$ref":"#/components/parameters/targetId"}],"get":{"operationId":"getTarget","tags":["Targets"],"summary":"Get a watchlist target","responses":{"200":{"description":"Target details","content":{"application/json":{"schema":{"type":"object","required":["target"],"properties":{"target":{"$ref":"#/components/schemas/WatchedTarget"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"patch":{"operationId":"updateTarget","tags":["Targets"],"summary":"Pause/resume a target, change its check cadence, or set its expiration","description":"Provide at least one of `paused`, `checkFrequencyMinutes`, or `expiresAt`.\n\nCredit preflight runs before the write. Resuming, shortening cadence, or extending\n`expiresAt` may return `402 credits_exceeded` when monthly commitment or runway would\nbe exceeded. Adding targets is never blocked.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","minProperties":1,"properties":{"paused":{"type":"boolean"},"checkFrequencyMinutes":{"$ref":"#/components/schemas/CheckFrequencyMinutes"},"expiresAt":{"type":"string","format":"date-time","nullable":true,"description":"Per-target watch end time (ISO 8601). Set to `null` to clear. Org-level `settings.expiresAt` also applies — whichever is earlier stops checks."}}},"examples":{"pause":{"summary":"Pause a target","value":{"paused":true}},"changeCadence":{"summary":"Check this target weekly instead of daily","value":{"checkFrequencyMinutes":10080}},"changeCadenceMinutes":{"summary":"Check this target every 15 minutes","value":{"checkFrequencyMinutes":15}},"setExpiration":{"summary":"Stop checking after a specific date","value":{"expiresAt":"2026-12-31T23:59:59.000Z"}},"clearExpiration":{"summary":"Remove per-target expiration (org default may still apply)","value":{"expiresAt":null}}}}}},"responses":{"200":{"description":"Updated target","content":{"application/json":{"schema":{"type":"object","required":["target"],"properties":{"target":{"$ref":"#/components/schemas/WatchedTarget"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"oneOf":[{"$ref":"#/components/responses/SubscriptionRequired"},{"$ref":"#/components/responses/CreditsExceeded"}]},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"delete":{"operationId":"deleteTarget","tags":["Targets"],"summary":"Remove a target from the watchlist","responses":{"204":{"description":"Target removed"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/v1/targets/{targetId}/posts":{"parameters":[{"$ref":"#/components/parameters/targetId"}],"get":{"operationId":"getTargetPosts","tags":["Targets"],"summary":"Fetch recent LinkedIn posts for a watchlist target","description":"Fetches up to ~100 recent original posts for an existing watchlist target.\nThis is a **read-only preview** — posts are **not** ingested into activities, webhooks\nare **not** sent, and `last_checked_at` is **not** updated.\n\n**Credits:** consumes **1 credit** on a successful fetch. Rejected with\n`402 credits_exceeded` when `checksRemaining` is less than 1. Credits are refunded when\nentity resolution or the fetch fails.\n\nOptional `limit` (1–100) returns the newest N posts after fetch.\n","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":100},"description":"Return at most this many posts (newest first). Defaults to all fetched (max ~100)."}],"responses":{"200":{"description":"Recent posts preview","content":{"application/json":{"schema":{"type":"object","required":["posts","meta"],"properties":{"posts":{"type":"array","items":{"$ref":"#/components/schemas/TargetPostPreview"}},"meta":{"type":"object","required":["targetId","limit","fetchedCount","creditsConsumed"],"properties":{"targetId":{"type":"string","format":"uuid"},"limit":{"type":"integer","nullable":true},"fetchedCount":{"type":"integer","description":"Posts returned before the optional limit is applied"},"creditsConsumed":{"type":"integer","enum":[1]}}}}}}}},"400":{"oneOf":[{"$ref":"#/components/responses/BadRequest"},{"description":"Invalid limit","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_limit"]}}}}}}]},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"oneOf":[{"$ref":"#/components/responses/SubscriptionRequired"},{"$ref":"#/components/responses/CreditsExceeded"}]},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"description":"LinkedIn entity could not be resolved for this target","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["entity_not_resolved"]},"message":{"type":"string"}}}}}},"429":{"$ref":"#/components/responses/TooManyRequests"},"502":{"description":"Upstream post fetch failed","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["fetch_failed"]},"message":{"type":"string"}}}}}},"503":{"description":"Service temporarily unavailable","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["service_unavailable"]},"message":{"type":"string"}}}}}}}}}},"webhooks":{"postAlertsActivityCreated":{"post":{"operationId":"postAlertsActivityCreated","tags":["Webhooks"],"summary":"New post detected","description":"Propelr sends this event to your configured `webhookUrl` whenever a watched profile or company\npublishes a new LinkedIn post. Delivery runs on that target's check cadence, not in real time —\nsee `WatchedTarget.checkFrequencyMinutes`.\n\nPropelr sends **one webhook per new post** — a target with two new posts since the last check\ntriggers two separate deliveries.\n\n## Setup\n\n1. Open **Post Alerts → Settings** in the Propelr app (or `PATCH /v1/settings` with a session JWT).\n2. Under **Webhooks**, enter your HTTPS endpoint URL and save.\n3. Propelr generates a **signing secret** the first time you save a webhook URL and shows it once —\n   copy it into your server (e.g. `PROPELR_WEBHOOK_SECRET`). Changing the webhook URL also rotates\n   the secret (shown once). Use **Rotate signing secret** to issue a new secret without changing\n   the URL.\n4. Your endpoint must accept `POST` with `Content-Type: application/json`, return `2xx` quickly,\n   and **verify the signature before parsing or acting on the payload**.\n\n## Delivery headers\n\nEach `POST` includes these headers (in addition to `Content-Type: application/json`):\n\n| Header | Example | Purpose |\n| --- | --- | --- |\n| `X-Propelr-Event` | `post_alerts.activity.created` | Event type |\n| `X-Propelr-Delivery-Id` | `f47ac10b-58cc-4372-a567-0e02b2c3d479` | Unique delivery ID — dedupe with `activity.id` |\n| `X-Propelr-Signature` | `t=1700000000,sha256=abc…` | HMAC signature (verify before trusting the body) |\n\n## Signing secret\n\n- Shown once in Settings as a **64-character hex string** (e.g. `a1b2c3…`).\n- Use that string **literally** as the HMAC key (UTF-8 bytes of the hex characters). Do **not**\n  decode it from hex into 32 raw bytes.\n- Store it in an environment variable on your server. Propelr never sends the secret in webhook\n  requests — only you and Propelr know it.\n\n## How to verify (step by step)\n\n1. **Read the raw request body** as a string or byte buffer **before** `JSON.parse` or any framework\n   body parser that might reformat whitespace or key order.\n2. **Parse `X-Propelr-Signature`** — format is `t=<unix_seconds>,sha256=<hex_digest>` (comma-separated).\n3. **Check the timestamp** — reject if `abs(now - t) > 300` seconds (5-minute replay window).\n4. **Build the signed string** — concatenate: `<unix_seconds> + \".\" + <raw_body>` (one dot, no spaces).\n5. **Compute HMAC-SHA256** of that string using your signing secret (UTF-8) and hex-encode the result.\n6. **Compare digests** — the computed hex must match `sha256=` from the header. Use a constant-time\n   comparison (`crypto.timingSafeEqual` / `hmac.compare_digest`). Return `401` on any failure.\n\n### Worked example\n\nGiven:\n\n- `X-Propelr-Signature`: `t=1700000000,sha256=…` (digest from Propelr)\n- Raw body (exact bytes received): `{\"event\":\"post_alerts.activity.created\",\"activity\":{…}}`\n- Signing secret: `aaaa…` (64 hex chars from Settings)\n\nThe string you HMAC is:\n\n```\n1700000000.{\"event\":\"post_alerts.activity.created\",\"activity\":{…}}\n```\n\nOnly if your computed SHA-256 hex matches the `sha256=` value in the header is the request authentic.\n\n### Capturing the raw body\n\nVerification fails if you sign a re-serialized JSON object instead of the original bytes.\n\n- **Node.js (Express)** — use `express.raw({ type: 'application/json' })` on the webhook route, or\n  read `req.body` as a `Buffer` before any JSON middleware runs.\n- **Next.js App Router** — call `await request.text()` (or `arrayBuffer()`) and verify before\n  `JSON.parse`.\n- **Python (Flask/FastAPI)** — use `request.get_data()` / `await request.body()` before parsing JSON.\n\n## Reference implementations\n\n```javascript Node.js\nimport crypto from 'crypto';\n\nfunction verifyPropelrWebhook(rawBody, signatureHeader, secret, maxAgeSeconds = 300) {\n  const parts = Object.fromEntries(\n    signatureHeader.split(',').map((p) => p.trim().split('='))\n  );\n  const timestamp = Number.parseInt(parts.t, 10);\n  const digest = (parts.sha256 || '').toLowerCase();\n  if (!Number.isFinite(timestamp) || !/^[a-f0-9]{64}$/.test(digest)) return false;\n\n  const now = Math.floor(Date.now() / 1000);\n  if (Math.abs(now - timestamp) > maxAgeSeconds) return false;\n\n  const expected = crypto\n    .createHmac('sha256', secret)\n    .update(`${timestamp}.${rawBody}`, 'utf8')\n    .digest('hex');\n\n  const expectedBuf = Buffer.from(expected, 'utf8');\n  const receivedBuf = Buffer.from(digest, 'utf8');\n  if (expectedBuf.length !== receivedBuf.length) return false;\n  return crypto.timingSafeEqual(expectedBuf, receivedBuf);\n}\n```\n\n```python Python\nimport hashlib\nimport hmac\nimport re\nimport time\n\ndef verify_propelr_webhook(raw_body: bytes, signature_header: str, secret: str, max_age_seconds: int = 300) -> bool:\n    parts = dict(p.split('=', 1) for p in signature_header.split(','))\n    try:\n        timestamp = int(parts.get('t', ''))\n    except ValueError:\n        return False\n\n    digest = (parts.get('sha256') or '').lower()\n    if not re.fullmatch(r'[a-f0-9]{64}', digest):\n        return False\n\n    now = int(time.time())\n    if abs(now - timestamp) > max_age_seconds:\n        return False\n\n    signed_payload = f\"{timestamp}.\".encode('utf-8') + raw_body\n    expected = hmac.new(secret.encode('utf-8'), signed_payload, hashlib.sha256).hexdigest()\n    return hmac.compare_digest(expected, digest)\n```\n\n## Common mistakes\n\n- Parsing JSON first, then `JSON.stringify` for verification — whitespace and key order will differ.\n- Using the secret as raw binary (decoding hex) instead of the literal 64-character string.\n- Comparing only `sha256=` without checking the `t=` timestamp (allows replay attacks).\n- Including the `sha256=` prefix in the HMAC output comparison (compare hex digests only).\n- Logging the signing secret or full signed payloads in production.\n\n## Security checklist\n\nReturn `401` for missing, malformed, or invalid signatures and for timestamps outside the 5-minute\nwindow. Deduplicate using `X-Propelr-Delivery-Id` and/or `activity.id`. Rotate the signing secret\nfrom **Settings → Webhooks** if it may have been exposed or after changing your endpoint URL.\n","parameters":[{"name":"X-Propelr-Event","in":"header","required":true,"schema":{"type":"string","example":"post_alerts.activity.created"},"description":"Event type. One of `post_alerts.activity.created` or `post_alerts.target.expired`."},{"name":"X-Propelr-Delivery-Id","in":"header","required":true,"schema":{"type":"string","format":"uuid","example":"f47ac10b-58cc-4372-a567-0e02b2c3d479"},"description":"Unique ID for this delivery attempt. Use to deduplicate alongside `activity.id`."},{"name":"X-Propelr-Signature","in":"header","required":true,"schema":{"type":"string","example":"t=1700000000,sha256=a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456"},"description":"HMAC-SHA256 signature. Format: `t=<unix_seconds>,sha256=<64_char_hex>`.\n\nSigned payload (UTF-8): `\"<unix_seconds>.<raw_request_body>\"` — the timestamp, a literal dot,\nthen the exact request body bytes. HMAC key: your 64-character hex signing secret as a UTF-8\nstring (not decoded from hex). Reject if timestamp is more than 5 minutes from server time.\nSee the operation description for step-by-step verification and code samples.\n"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostAlertsActivityCreatedEvent"},"examples":{"person":{"summary":"Person target","value":{"event":"post_alerts.activity.created","activity":{"id":"f47ac10b-58cc-4372-a567-0e02b2c3d479","externalPostId":"urn:li:activity:1234567890","post":"Excited to share our latest product update! We've been working hard on this release and can't wait for you all to try it. Full details in the comments.","postUrl":"https://www.linkedin.com/feed/update/urn:li:activity:1234567890","postedAt":"2026-06-10T14:30:00.000Z","edited":false,"engagement":{"totalReactions":25,"commentsCount":4,"repostsCount":2,"reactions":[{"type":"LIKE","count":23},{"type":"EMPATHY","count":2}]},"media":[],"isNew":true,"meta":{"contacted":false,"dismissed":false}},"target":{"id":"bc1b528e-d03c-4f7d-8870-e4f5b73b0eb9","type":"person","name":"Jane Example","profileUrl":"https://www.linkedin.com/in/jane-example/","checkFrequencyMinutes":1440,"paused":false}}},"company":{"summary":"Company target","value":{"event":"post_alerts.activity.created","activity":{"id":"6ba7b810-9dad-11d1-80b4-00c04fd430c8","externalPostId":"urn:li:activity:9876543210","post":"We're hiring across engineering and sales! Check out our open roles and come build with us — link in comments.","postUrl":"https://www.linkedin.com/feed/update/urn:li:activity:9876543210","postedAt":"2026-06-10T11:00:00.000Z","edited":false,"engagement":{"totalReactions":8,"commentsCount":1,"repostsCount":0,"reactions":[{"type":"LIKE","count":7},{"type":"PRAISE","count":1}]},"media":[{"type":"image","url":"https://media.licdn.com/dms/image/example-share.jpg"}],"isNew":true,"meta":{"contacted":false,"dismissed":false}},"target":{"id":"093b0e51-3f72-470c-93e9-2f493ce3ec57","type":"company","name":"Acme Corp","profileUrl":"https://www.linkedin.com/company/acme-corp/","checkFrequencyMinutes":10080,"paused":false}}}}}}},"responses":{"200":{"description":"Propelr only checks the HTTP status code — the response body is ignored. Return any `2xx`\nonce you have safely persisted or queued the event.\n"},"204":{"description":"Acknowledged with no content."},"default":{"description":"Any non-`2xx` status (or a 30s timeout) marks the delivery as failed. Propelr makes two\nimmediate attempts per delivery. Failed deliveries are retried on subsequent scheduler ticks\nuntil your endpoint returns `2xx`.\n"}}}},"postAlertsTargetExpired":{"post":{"operationId":"postAlertsTargetExpired","tags":["Webhooks"],"summary":"Watch target expired","description":"Propelr sends this event to your configured `webhookUrl` when a watched profile or company's\neffective expiration date passes. The target is **automatically paused** and checks stop.\n\nEffective expiration is the **earlier** of the target's own `expiresAt` and your org-level\n`settings.expiresAt`. Propelr sends **one webhook per expiration** — if you later extend the\nexpiration and it passes again, you'll receive another delivery.\n\nUses the same signing scheme as `post_alerts.activity.created`:\n\n1. Read the **raw** request body before JSON parsing.\n2. Parse `X-Propelr-Signature` as `t=<unix_seconds>,sha256=<hex>`.\n3. Reject if the timestamp is outside a **5-minute** window.\n4. HMAC-SHA256 `\"<t>.<raw_body>\"` with your signing secret (64-char hex string, UTF-8).\n5. Constant-time compare the computed hex to `sha256=`; return `401` on failure.\n\nSee `post_alerts.activity.created` for headers table, worked example, framework notes, and full\nNode.js / Python samples.\n","parameters":[{"name":"X-Propelr-Event","in":"header","required":true,"schema":{"type":"string","example":"post_alerts.target.expired"},"description":"Event type. Always `post_alerts.target.expired`."},{"name":"X-Propelr-Delivery-Id","in":"header","required":true,"schema":{"type":"string","format":"uuid"},"description":"Unique ID for this delivery attempt."},{"name":"X-Propelr-Signature","in":"header","required":true,"schema":{"type":"string","example":"t=1700000000,sha256=a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456"},"description":"Same HMAC scheme as `post_alerts.activity.created`: `t=<unix_seconds>,sha256=<hex>` over\n`\"<unix_seconds>.<raw_body>\"`. See that event's description for full verification steps.\n"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostAlertsTargetExpiredEvent"},"examples":{"targetExpiration":{"summary":"Per-target expiration","value":{"event":"post_alerts.target.expired","target":{"id":"bc1b528e-d03c-4f7d-8870-e4f5b73b0eb9","type":"person","name":"Jane Example","profileUrl":"https://www.linkedin.com/in/jane-example/","checkFrequencyMinutes":1440,"paused":true,"expiresAt":"2026-06-30T23:59:59.000Z"},"expiration":{"effectiveExpiresAt":"2026-06-30T23:59:59.000Z","targetExpiresAt":"2026-06-30T23:59:59.000Z","reason":"target_expiration"}}},"orgExpiration":{"summary":"Org-wide expiration applied","value":{"event":"post_alerts.target.expired","target":{"id":"093b0e51-3f72-470c-93e9-2f493ce3ec57","type":"company","name":"Acme Corp","profileUrl":"https://www.linkedin.com/company/acme-corp/","checkFrequencyMinutes":10080,"paused":true,"expiresAt":null},"expiration":{"effectiveExpiresAt":"2026-12-31T23:59:59.000Z","targetExpiresAt":null,"reason":"org_expiration"}}}}}}},"responses":{"200":{"description":"Acknowledged — return any `2xx` once the event is safely persisted or queued."},"204":{"description":"Acknowledged with no content."},"default":{"description":"Non-`2xx` marks delivery failed; Propelr retries once (two attempts total)."}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","x-default":"propelr_live_your_api_key_here","description":"Paste your org API key (`propelr_live_…`). Organization scope is embedded in the key —\nnever pass `organizationId` as a query or body field.\n\nAPI keys work for targets, credits, activities, and settings GET. Settings PATCH and\n`POST /v1/settings/webhook-secret/rotate` require a Supabase session JWT from the\nPropelr dashboard. API keys themselves are created, rotated, and revoked only from\n**Post Alerts → Settings** — there is no public endpoint for managing them.\n"}},"parameters":{"targetId":{"name":"targetId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}},"responses":{"BadRequest":{"description":"Invalid request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Unauthorized":{"description":"Missing or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Forbidden":{"description":"Forbidden or feature not enabled","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"NotFound":{"description":"Resource not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"SubscriptionRequired":{"description":"The organization has no active Post Alerts subscription (or plan) Subscribe from **Post Alerts → Settings → Plan & billing** before adding or resuming targets.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubscriptionRequiredError"}}}},"CreditsExceeded":{"description":"The requested change would exceed the organization's credit budget — either the full-cycle cadence commitment (`projectedChecksPerMonthAfterChange` > `checksAllowed`) or the rest-of-cycle commitment (`checksPlannedRestOfCycleAfterChange` + `checksUsedThisCycle` > `checksAllowed`). Reduce cadences, pause targets, or upgrade the plan. No partial writes are applied.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreditsExceededError"}}}},"TooManyRequests":{"description":"Rate limit exceeded for your IP address — see **Rate limits** above. Retry after the number of seconds in the `Retry-After` header.","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until you can retry."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"rate_limit_exceeded"}}}}},"schemas":{"Error":{"type":"object","required":["error"],"properties":{"error":{"type":"string"}}},"ErrorWithExisting":{"type":"object","properties":{"error":{"type":"string","enum":["duplicate","invalid"]},"existing":{"$ref":"#/components/schemas/WatchedTarget"}}},"SubscriptionRequiredError":{"type":"object","required":["error","upgradeUrl"],"properties":{"error":{"type":"string","enum":["subscription_required"]},"upgradeUrl":{"type":"string","example":"/dashboard/watchlist/settings"}}},"CreditsExceededError":{"type":"object","required":["error","upgradeUrl"],"properties":{"error":{"type":"string","enum":["credits_exceeded"]},"message":{"type":"string","example":"This change would exceed your monthly credit budget"},"checksAllowed":{"type":"integer","example":36000},"checksUsedThisCycle":{"type":"integer","example":172},"checksPlannedRestOfCycle":{"type":"integer","description":"Planned checks for the rest of the cycle **before** the rejected change.","example":16146},"projectedChecksPerMonth":{"type":"integer","description":"Full-cycle cadence commitment **before** the rejected change.","example":17280},"projectedChecksPerMonthAfterChange":{"type":"integer","description":"Full-cycle cadence commitment if the change were applied.","example":52000},"checksPlannedRestOfCycleAfterChange":{"type":"integer","description":"Rest-of-cycle planned checks if the change were applied.","example":31000},"upgradeUrl":{"type":"string","example":"/dashboard/watchlist/settings"}}},"CheckFrequencyMinutes":{"type":"integer","minimum":5,"description":"How often (in minutes) a target is checked for new posts. Fully flexible — any integer 5 or greater. 1 check = 1 credit. Common values: `5`–`55` (minutes), `60`, `120`, `360` (hours), `1440` (daily), `10080` (weekly), `43200` (~monthly).","example":1440},"Credits":{"type":"object","description":"Billing-cycle check budget returned by GET /v1/credits. Derived fields (checksCommittedThisCycle, checksRemaining) are computed server-side on every request.","required":["checksAllowed","checksUsedThisCycle","checksPlannedRestOfCycle","checksCommittedThisCycle","checksRemaining","checksResetsAt","projectedChecksPerMonth"],"properties":{"checksAllowed":{"type":"integer","minimum":0,"description":"Monthly check allowance from the org's Post Alerts plan. One credit equals one target check. Unaffected by how many targets are on the watchlist.","example":36000},"checksUsedThisCycle":{"type":"integer","minimum":0,"description":"Successful checks already recorded in the credit ledger this billing cycle, counted from checksCycleStartsAt (inclusive). Each completed scrape of one target consumes one credit.","example":172},"checksPlannedRestOfCycle":{"type":"integer","minimum":0,"description":"Forward-looking count of checks still scheduled from now until checksResetsAt at each active target's current checkFrequencyMinutes. Excludes paused and expired targets.","example":16146},"checksCommittedThisCycle":{"type":"integer","description":"Derived total for this cycle: checksUsedThisCycle + checksPlannedRestOfCycle. Everything already spent plus everything still on the calendar before reset.","example":16318},"checksRemaining":{"type":"integer","description":"Derived budget headroom: checksAllowed - checksCommittedThisCycle. Positive means uncommitted allowance remains. Zero means fully allocated. Negative means overcommitted for the cycle.","example":19682},"checksCycleStartsAt":{"type":"string","format":"date-time","nullable":true,"description":"ISO 8601 start of the current billing cycle. Lower bound for checksUsedThisCycle ledger queries. Null when the cycle start cannot be determined.","example":"2026-07-05T00:00:00.000Z"},"checksResetsAt":{"type":"string","format":"date-time","nullable":true,"description":"ISO 8601 when the monthly allowance refreshes for the next billing cycle. Upper bound for checksPlannedRestOfCycle projections.","example":"2026-08-05T00:00:00.000Z"},"projectedChecksPerMonth":{"type":"integer","minimum":0,"description":"Steady-state monthly burn at current cadences over a full 30-day period: sum of (43,200 ÷ checkFrequencyMinutes) per active target. Matches dashboard \"~X checks/mo\" estimates. Used for monthly cadence preflight; distinct from checksPlannedRestOfCycle, which only covers the rest of the current cycle.","example":17280}}},"CreditUsageSummary":{"type":"object","required":["checksAllowed","checksUsedThisCycle","checksPlannedRestOfCycle","checksCommittedThisCycle","checksRemaining","checksResetsAt","projectedChecksPerMonth"],"properties":{"checksAllowed":{"type":"integer","description":"Plan allowance for the current billing cycle."},"checksUsedThisCycle":{"type":"integer","description":"Checks already run this cycle."},"checksPlannedRestOfCycle":{"type":"integer","description":"Checks still scheduled until checksResetsAt."},"checksCommittedThisCycle":{"type":"integer","description":"checksUsedThisCycle + checksPlannedRestOfCycle."},"checksRemaining":{"type":"integer","description":"checksAllowed - checksCommittedThisCycle."},"checksResetsAt":{"type":"string","format":"date-time","nullable":true,"description":"When the credit balance resets."},"projectedChecksPerMonth":{"type":"integer","description":"Full-cycle cadence commitment across active targets."},"recordedChecksThisCycle":{"type":"integer","description":"Successful checks recorded in the ledger since the current cycle started."}}},"CreditUsageEvent":{"type":"object","required":["id","targetId","targetName","targetType","profileUrl","runId","credits","consumedAt"],"properties":{"id":{"type":"string","format":"uuid"},"targetId":{"type":"string","format":"uuid"},"targetName":{"type":"string"},"targetType":{"type":"string","enum":["person","company"]},"profileUrl":{"type":"string","format":"uri"},"runId":{"type":"string","format":"uuid","nullable":true,"description":"Check run that consumed this credit, when available."},"credits":{"type":"integer","minimum":1,"example":1},"consumedAt":{"type":"string","format":"date-time"}}},"CreditUsagePage":{"type":"object","required":["summary","events","nextCursor","hasMore"],"properties":{"summary":{"$ref":"#/components/schemas/CreditUsageSummary"},"events":{"type":"array","items":{"$ref":"#/components/schemas/CreditUsageEvent"}},"nextCursor":{"type":"string","nullable":true},"hasMore":{"type":"boolean"}}},"TargetsListPage":{"type":"object","required":["targets","nextCursor","hasMore"],"properties":{"targets":{"type":"array","items":{"$ref":"#/components/schemas/WatchedTarget"}},"nextCursor":{"type":"string","nullable":true,"description":"Pass as `cursor` on the next request, or `null` when there are no more pages."},"hasMore":{"type":"boolean"}}},"TargetListStats":{"type":"object","required":["totalCount","activeCount","projectedChecksPerMonth"],"properties":{"totalCount":{"type":"integer","minimum":0},"activeCount":{"type":"integer","minimum":0,"description":"Non-paused, non-expired targets."},"projectedChecksPerMonth":{"type":"integer","minimum":0,"description":"Estimated monthly credit usage across active targets."}}},"WatchedTarget":{"type":"object","required":["id","type","name","profileUrl","addedAt","checkFrequencyMinutes"],"properties":{"id":{"type":"string","format":"uuid","description":"Target ID — use for `GET`/`PATCH`/`DELETE /v1/targets/{targetId}` and to correlate webhook deliveries back to this watchlist entry (see `WebhookTarget.id`)."},"type":{"type":"string","enum":["person","company"]},"name":{"type":"string"},"profileUrl":{"type":"string","format":"uri"},"addedAt":{"type":"string","format":"date-time"},"paused":{"type":"boolean"},"lastPostAt":{"type":"string","format":"date-time","nullable":true},"lastCheckedAt":{"type":"string","format":"date-time","nullable":true,"description":"Last time a check actually ran for this target, regardless of whether a new post was found. `null` if it has never been checked yet."},"checkFrequencyMinutes":{"$ref":"#/components/schemas/CheckFrequencyMinutes"},"expiresAt":{"type":"string","format":"date-time","nullable":true,"description":"Per-target watch end time. Combined with org-level `settings.expiresAt` at check time — the earlier of the two dates stops checks for this target."},"addedBy":{"type":"object","properties":{"name":{"type":"string"},"isYou":{"type":"boolean"}}}}},"CreateTargetByUrl":{"type":"object","required":["profileUrl"],"properties":{"profileUrl":{"type":"string","format":"uri"},"checkFrequencyMinutes":{"allOf":[{"$ref":"#/components/schemas/CheckFrequencyMinutes"}],"description":"Defaults to your org's default check frequency when omitted."},"expiresAt":{"type":"string","format":"date-time","nullable":true,"description":"Per-target watch end time. Defaults to your org's `settings.expiresAt` when omitted on create."}}},"CreateTargetsByUrls":{"type":"object","required":["urls"],"properties":{"urls":{"type":"array","items":{"type":"string","format":"uri"},"minItems":1},"checkFrequencyMinutes":{"allOf":[{"$ref":"#/components/schemas/CheckFrequencyMinutes"}],"description":"Applied to every target in this batch. Defaults to your org's default check frequency when omitted."},"expiresAt":{"type":"string","format":"date-time","nullable":true,"description":"Per-target watch end time for every URL in this batch. Defaults to your org's `settings.expiresAt` when omitted."}}},"CreateTargetByLead":{"type":"object","required":["leadId"],"properties":{"leadId":{"type":"string","format":"uuid"},"checkFrequencyMinutes":{"allOf":[{"$ref":"#/components/schemas/CheckFrequencyMinutes"}],"description":"Defaults to your org's default check frequency when omitted."},"expiresAt":{"type":"string","format":"date-time","nullable":true,"description":"Per-target watch end time. Defaults to your org's `settings.expiresAt` when omitted on create."}}},"BulkAddResult":{"type":"object","required":["added","duplicates","failed","targets"],"properties":{"added":{"type":"integer"},"duplicates":{"type":"integer"},"failed":{"type":"integer"},"targets":{"type":"array","items":{"$ref":"#/components/schemas/WatchedTarget"}}}},"NotificationSettings":{"type":"object","required":["webhookUrl","checkFrequencyMinutes","expiresAt","notificationTime","timezone"],"properties":{"webhookUrl":{"type":"string","description":"The URL activity notifications are delivered to. Empty string if webhooks aren't configured yet."},"checkFrequencyMinutes":{"allOf":[{"$ref":"#/components/schemas/CheckFrequencyMinutes"}],"description":"Default check cadence applied to newly added targets. Existing targets keep their own `checkFrequencyMinutes` even if this default changes."},"expiresAt":{"type":"string","format":"date-time","nullable":true,"description":"When monitoring stops. Null means never expires."},"notificationTime":{"type":"string","nullable":true,"example":"09:00","description":"Local time (HH:MM) for daily briefing notifications"},"timezone":{"type":"string","example":"America/New_York","description":"IANA timezone used to interpret `notificationTime` and to schedule checks and briefing emails."},"briefingEmailEnabled":{"type":"boolean","default":true,"description":"When false, summary briefing emails are not sent to org members"},"webhookSigningSecretConfigured":{"type":"boolean","description":"True when an HMAC signing secret exists for webhook verification"}}},"NotificationSettingsPatch":{"type":"object","properties":{"webhookUrl":{"type":"string","description":"A URL that's publicly reachable from the internet. Local addresses (e.g. `localhost`) and private network addresses aren't accepted — use a public endpoint such as one exposed via ngrok, a cloud function, or your production server. Invalid URLs return a 400 error."},"checkFrequencyMinutes":{"allOf":[{"$ref":"#/components/schemas/CheckFrequencyMinutes"}],"description":"New default check cadence for newly added targets. Does not change existing targets."},"expiresAt":{"type":"string","format":"date-time","nullable":true},"notificationTime":{"type":"string","nullable":true},"timezone":{"type":"string","example":"America/New_York","description":"IANA timezone, e.g. `America/New_York` or `Europe/London`."},"briefingEmailEnabled":{"type":"boolean"}}},"TargetPostPreview":{"type":"object","required":["externalPostId","post","postUrl","postedAt","edited","engagement","media"],"description":"Ephemeral post preview from GET /v1/targets/{targetId}/posts. Not persisted as a watch activity — no Propelr activity id, targetId, isNew, or meta fields.","properties":{"externalPostId":{"type":"string","description":"LinkedIn post URN (e.g. `urn:li:activity:1234567890`)","example":"urn:li:activity:1234567890"},"post":{"type":"string","description":"Full post text"},"postUrl":{"type":"string","format":"uri"},"postedAt":{"type":"string","format":"date-time"},"edited":{"type":"boolean"},"engagement":{"$ref":"#/components/schemas/ActivityEngagement"},"media":{"type":"array","items":{"$ref":"#/components/schemas/ActivityMediaItem"}}}},"WatchActivity":{"type":"object","required":["id","targetId","post","postUrl","postedAt","isNew"],"properties":{"id":{"type":"string","format":"uuid","description":"Propelr activity UUID — use this to address the activity via `PATCH /v1/activities/{activityId}`.","example":"f47ac10b-58cc-4372-a567-0e02b2c3d479"},"externalPostId":{"type":"string","description":"The post's LinkedIn URN (e.g. `urn:li:activity:1234567890`) — use this to deduplicate webhook retries across delivery attempts.","example":"urn:li:activity:1234567890"},"targetId":{"type":"string","format":"uuid","description":"Watchlist target this post belongs to (see `WatchedTarget.id`)"},"post":{"type":"string","description":"The full text of the post"},"postUrl":{"type":"string","format":"uri"},"postedAt":{"type":"string","format":"date-time"},"edited":{"type":"boolean","description":"Whether the author edited the post on LinkedIn after publishing"},"engagement":{"$ref":"#/components/schemas/ActivityEngagement"},"media":{"type":"array","description":"Images, articles, or other media attached to the post","items":{"$ref":"#/components/schemas/ActivityMediaItem"}},"isNew":{"type":"boolean"},"meta":{"$ref":"#/components/schemas/ActivityMeta"}}},"ActivityEngagement":{"type":"object","nullable":true,"description":"LinkedIn reaction/comment/repost counts captured when the post was detected. `null` if unavailable.","properties":{"totalReactions":{"type":"integer"},"commentsCount":{"type":"integer"},"repostsCount":{"type":"integer"},"reactions":{"type":"array","description":"Reaction counts broken down by type (e.g. `LIKE`, `PRAISE`, `EMPATHY`)","items":{"type":"object","properties":{"type":{"type":"string","example":"LIKE"},"count":{"type":"integer"}}}}}},"ActivityMediaItem":{"type":"object","properties":{"type":{"type":"string","description":"Media kind, e.g. `image`, `article`, or `video`"},"url":{"type":"string","format":"uri"}}},"ActivityMeta":{"type":"object","properties":{"contacted":{"type":"boolean"},"contactedAt":{"type":"string","format":"date-time"},"dismissed":{"type":"boolean"}}},"PostAlertsActivityCreatedEvent":{"type":"object","required":["event","activity","target"],"properties":{"event":{"type":"string","enum":["post_alerts.activity.created"]},"activity":{"$ref":"#/components/schemas/WebhookActivity"},"target":{"$ref":"#/components/schemas/WebhookTarget"}}},"PostAlertsTargetExpiredEvent":{"type":"object","required":["event","target","expiration"],"properties":{"event":{"type":"string","enum":["post_alerts.target.expired"]},"target":{"$ref":"#/components/schemas/WebhookTarget"},"expiration":{"type":"object","required":["effectiveExpiresAt","reason"],"properties":{"effectiveExpiresAt":{"type":"string","format":"date-time","nullable":true,"description":"The expiration instant that triggered this event (earlier of target and org)."},"targetExpiresAt":{"type":"string","format":"date-time","nullable":true,"description":"The target's own `expiresAt` at the time of the event."},"reason":{"type":"string","enum":["target_expiration","org_expiration","effective_expiration"],"description":"Which expiration rule caused the target to stop."}}}}},"WebhookActivity":{"type":"object","required":["id","externalPostId","post","postUrl","postedAt","isNew","meta"],"properties":{"id":{"type":"string","format":"uuid","description":"Propelr activity UUID — correlate deliveries back to `GET /v1/activities/{activityId}`.","example":"f47ac10b-58cc-4372-a567-0e02b2c3d479"},"externalPostId":{"type":"string","description":"The post's LinkedIn URN (e.g. `urn:li:activity:1234567890`) — use this to deduplicate webhook retries across delivery attempts.","example":"urn:li:activity:1234567890"},"post":{"type":"string","description":"The full text of the post"},"postUrl":{"type":"string","format":"uri","description":"Direct link to the LinkedIn post"},"postedAt":{"type":"string","format":"date-time","description":"When the post was published on LinkedIn"},"edited":{"type":"boolean","description":"Whether the author edited the post on LinkedIn after publishing"},"engagement":{"$ref":"#/components/schemas/ActivityEngagement"},"media":{"type":"array","description":"Images, articles, or other media attached to the post","items":{"$ref":"#/components/schemas/ActivityMediaItem"}},"isNew":{"type":"boolean","description":"Whether the activity is unread in the briefing"},"meta":{"$ref":"#/components/schemas/ActivityMeta"}}},"WebhookTarget":{"type":"object","required":["id","type","name","profileUrl","checkFrequencyMinutes","paused"],"properties":{"id":{"type":"string","format":"uuid","description":"The watchlist target's ID — same value as `WatchedTarget.id` from the REST API. Use this to correlate a webhook delivery back to a specific `GET /v1/targets/{targetId}` entry.","example":"bc1b528e-d03c-4f7d-8870-e4f5b73b0eb9"},"type":{"type":"string","enum":["person","company"]},"name":{"type":"string","description":"Display name"},"profileUrl":{"type":"string","format":"uri","description":"LinkedIn profile or company page URL"},"checkFrequencyMinutes":{"allOf":[{"$ref":"#/components/schemas/CheckFrequencyMinutes"}],"description":"This target's current check cadence at the time the activity was found."},"paused":{"type":"boolean","description":"Whether this target is currently paused. Always `true` for `post_alerts.target.expired`."},"expiresAt":{"type":"string","format":"date-time","nullable":true,"description":"Per-target watch end time at the time of the event."}}}}}}