twenty-app-intake 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +384 -8
  2. package/manifest.json +424 -21
  3. package/package.json +1 -1
  4. package/src/front-components/settings-panel.mjs +7 -7
  5. package/src/front-components/settings-panel.mjs.map +2 -2
  6. package/src/front-components/settings-panel.tsx +49 -0
  7. package/src/logic-functions/check-silence.mjs +263 -0
  8. package/src/logic-functions/check-silence.mjs.map +7 -0
  9. package/src/logic-functions/check-silence.ts +52 -0
  10. package/src/logic-functions/contract.mjs +507 -0
  11. package/src/logic-functions/contract.mjs.map +7 -0
  12. package/src/logic-functions/contract.ts +172 -0
  13. package/src/logic-functions/health.mjs +125 -5
  14. package/src/logic-functions/health.mjs.map +3 -3
  15. package/src/logic-functions/health.ts +41 -5
  16. package/src/logic-functions/quarantine-discard.mjs +380 -0
  17. package/src/logic-functions/quarantine-discard.mjs.map +7 -0
  18. package/src/logic-functions/quarantine-discard.ts +65 -0
  19. package/src/logic-functions/quarantine-list.mjs +211 -0
  20. package/src/logic-functions/quarantine-list.mjs.map +7 -0
  21. package/src/logic-functions/quarantine-list.ts +93 -0
  22. package/src/logic-functions/quarantine-release.mjs +2188 -0
  23. package/src/logic-functions/quarantine-release.mjs.map +7 -0
  24. package/src/logic-functions/quarantine-release.ts +94 -0
  25. package/src/logic-functions/register.mjs +34 -1
  26. package/src/logic-functions/register.mjs.map +2 -2
  27. package/src/logic-functions/replay-bulk.mjs +2254 -0
  28. package/src/logic-functions/replay-bulk.mjs.map +7 -0
  29. package/src/logic-functions/replay-bulk.ts +83 -0
  30. package/src/logic-functions/replay-log.mjs +2201 -0
  31. package/src/logic-functions/replay-log.mjs.map +7 -0
  32. package/src/logic-functions/replay-log.ts +54 -0
  33. package/src/logic-functions/retry.mjs +1236 -269
  34. package/src/logic-functions/retry.mjs.map +4 -4
  35. package/src/logic-functions/retry.ts +45 -68
  36. package/src/logic-functions/test-ingest.mjs +851 -48
  37. package/src/logic-functions/test-ingest.mjs.map +4 -4
  38. package/src/logic-functions/test-ingest.ts +136 -28
  39. package/src/logic-functions/webhook.mjs +1200 -230
  40. package/src/logic-functions/webhook.mjs.map +4 -4
  41. package/src/logic-functions/webhook.ts +23 -48
  42. package/src/post-install.mjs +34 -8
  43. package/src/post-install.mjs.map +2 -2
  44. package/src/post-install.ts +8 -8
package/README.md CHANGED
@@ -63,7 +63,7 @@ Any JSON payload
63
63
  ② Classify short values → CRM fields │ prose / UTMs → note
64
64
 
65
65
 
66
- ③ Extend unknown fields → auto-create ext_ custom fields on Person or Company
66
+ ③ Extend unknown fields → auto-create ext custom fields on Person, Company or Opportunity
67
67
 
68
68
 
69
69
  ④ Deduplicate match by email (Person) or domain (Company) before creating anything
@@ -113,7 +113,33 @@ curl -X POST https://your-crm.com/s/intake/contact-form/test \
113
113
  -d '{"first_name":"Jane","email":"jane@co.com","budget":"15000"}'
114
114
  ```
115
115
 
116
- Returns exactly what *would* be created — standard fields, custom fields to create, note preview — without touching the CRM.
116
+ Returns a structured diff of what *would* happen, without touching the CRM: per object
117
+ whether it would be **created or updated**, which existing record it matched and on
118
+ what, what each field would do to the value already there, and which fields do not
119
+ exist yet and would be added to the schema. Plus the spam score the payload would get.
120
+
121
+ ```jsonc
122
+ {
123
+ "dryRun": true,
124
+ "diff": {
125
+ "objects": [{
126
+ "object": "person",
127
+ "operation": "update",
128
+ "recordId": "8f21…",
129
+ "matchedBy": { "field": "emails.primaryEmail", "value": "jane@co.com" },
130
+ "fields": [
131
+ { "name": "name", "status": "preserved", "existing": {…}, "incoming": {…} },
132
+ { "name": "phones", "status": "fill-empty", "incoming": {…} },
133
+ { "name": "extBudget", "status": "create", "fieldWouldBeCreated": true }
134
+ ],
135
+ "fieldsToCreate": [{ "name": "extBudget", "type": "NUMBER" }]
136
+ }]
137
+ }
138
+ }
139
+ ```
140
+
141
+ Because it is machine-readable, this is the gate to put in front of live traffic —
142
+ assert on it in a deployment check rather than reading it by eye.
117
143
 
118
144
  ---
119
145
 
@@ -177,6 +203,18 @@ Intake accepts any valid JSON. No required fields.
177
203
  | `utm_source/medium/campaign/content/term` | Note (always) |
178
204
  | `message`, `description`, `notes`, `comments`, `analysis` | Note (always) |
179
205
 
206
+ Key matching ignores case and separator style, so `Email`, `email`, `E-Mail`, `EMAIL`
207
+ and `emailAddress` all reach the same mapping. Nested payloads are flattened first —
208
+ `{"contact": {"first_name": "Jane"}}` becomes `contactFirstName`.
209
+
210
+ > **Fixed in 0.5.0.** A key that merely *started* with a capital — `Email`, `Phone`,
211
+ > `First Name`, the default labels most form builders send — missed the built-in map
212
+ > entirely and fell through to passthrough, producing a person with an `extEmail`
213
+ > column and no email address on the record. Because deduplication runs on the email,
214
+ > every later submission from that person created another copy. If a source of yours
215
+ > sends capitalised keys, look for `extEmail`, `extPhone` and `extFirstName` columns
216
+ > left behind by earlier runs.
217
+
180
218
  Unknown fields get an `ext_` prefix and are created as custom fields the first time they appear.
181
219
 
182
220
  ---
@@ -187,13 +225,130 @@ Add `IntakeFieldRule` records to extend or override the built-in map for a speci
187
225
 
188
226
  | Field | Description |
189
227
  |---|---|
190
- | `inputPattern` | Exact key name or JavaScript regex |
191
- | `canonicalName` | Target field in Twenty (use `ext` prefix for custom fields) |
192
- | `fieldType` | `TEXT`, `NUMBER`, `LINKS`, `EMAILS`, `PHONES`, `BOOLEAN`, `DATE_TIME`, `NOTE`, or `SKIP` |
228
+ | `inputPattern` | Exact key name or JavaScript regex — see [How a pattern is matched](#how-a-pattern-is-matched) |
229
+ | `canonicalName` | Target field in Twenty. An `ext`-prefixed name (`extBudget`) is created automatically; any other name must already exist on the target object |
230
+ | `fieldType` | `TEXT`, `NUMBER`, `BOOLEAN`, `DATE`, `DATE_TIME`, `CURRENCY`, `LINKS`, `EMAILS`, `PHONES`, `RICH_TEXT`, `RAW_JSON`, `NOTE`, or `SKIP` |
231
+ | `targetObject` | `AUTO` (default), `PERSON`, `COMPANY`, or `OPPORTUNITY` |
232
+ | `mergeStrategy` | `INHERIT` (default), `PRESERVE`, or `NEWEST_WINS` — see [Updating existing records](#updating-existing-records) |
193
233
  | `priority` | Higher = checked first (0–100) |
194
234
 
195
235
  Rules with no source linked apply globally across all sources.
196
236
 
237
+ ### Which mapping wins
238
+
239
+ Every incoming key is resolved in this order:
240
+
241
+ 1. **Your rules**, highest `priority` first
242
+ 2. **The built-in map**
243
+ 3. **Passthrough** — an `ext`-prefixed custom field with an auto-detected type
244
+
245
+ A rule beats the built-in map. That is what makes a rule worth configuring: it is
246
+ how `utm_source` becomes a real field instead of a line in the note, and how a
247
+ `website` goes somewhere other than the company's domain.
248
+
249
+ > **Changed in 0.5.0.** Before 0.5.0 the built-in map was consulted *first*, so a
250
+ > rule naming any of the 100+ built-in keys was silently ignored — the opposite of
251
+ > what this page has always described. If you wrote a rule against a built-in key
252
+ > and worked around it doing nothing, that workaround is now live. Check any rule
253
+ > matching `utm_*`, `website`, `url`, `domain`, `message`, `notes`, `city`, `state`
254
+ > or `country` before upgrading.
255
+
256
+ **One exception.** A rule cannot take a contact's `name`, `email` or `phone` away
257
+ from the contact — it may restate the mapping and set the type, target and merge
258
+ strategy, but it cannot send the value to a different field, to the note, or to the
259
+ bin. A person with no email cannot be found again, so the next submission from the
260
+ same address would create a duplicate, and the one after that another. When a rule
261
+ is turned away for this reason the ingest log says so by name.
262
+
263
+ To put a second address or a company switchboard somewhere of your own, match a key
264
+ the built-in map does not already own — `company_email`, `alt_phone`, `direct_line`.
265
+ Those behave like any other key.
266
+
267
+ ### How a pattern is matched
268
+
269
+ `inputPattern` is tried as an exact key name first, then as a JavaScript regex
270
+ (case-insensitive). Both are tested against **both spellings of the key**: the
271
+ literal key as it arrived, and its `snake_case` form.
272
+
273
+ Nested payloads are flattened to camelCase before rules run, so `{"lead": {"id": 1}}`
274
+ arrives as `leadId`. Writing `^lead_id$` or `^leadId$` therefore both work, and so
275
+ does the bare string `lead_id`.
276
+
277
+ > **Fixed in 0.5.0.** Patterns were previously tested against the camelCased key
278
+ > only, so `^lead_id$` never matched anything while the bare string `lead_id` matched
279
+ > fine. Patterns without underscores — `budget`, `amount`, `gclid` — were unaffected,
280
+ > which made the failure look arbitrary. A regex that was silently dead may now start
281
+ > matching; check any pattern you wrote with an underscore in it.
282
+
283
+ A pattern that is not valid regex still works as an exact key match rather than
284
+ being discarded.
285
+
286
+ ### Writing to the Opportunity
287
+
288
+ Deal attributes — the service someone asked for, the budget they stated, your own
289
+ lead id — belong on the Opportunity, not the contact. Two ways to put them there:
290
+
291
+ **A prefix, no configuration.** Any incoming key beginning `opportunity_`, `opp_`
292
+ or `deal_` is routed to the deal, and the prefix is stripped before the field is
293
+ named — `opportunity_budget` becomes `extBudget` on the Opportunity.
294
+
295
+ ```jsonc
296
+ { "email": "jane@acme.com", "opportunity_budget": "25000", "opportunity_service": "SEO" }
297
+ ```
298
+
299
+ **A rule, for keys you cannot rename.** Set `targetObject: OPPORTUNITY` on the rule
300
+ and point `canonicalName` at the field you want written:
301
+
302
+ | inputPattern | canonicalName | targetObject | fieldType |
303
+ |---|---|---|---|
304
+ | `service` | `machinaService` | `OPPORTUNITY` | `TEXT` |
305
+ | `budget` | `amount` | `OPPORTUNITY` | `CURRENCY` |
306
+
307
+ `amount` and `closeDate` are standard Opportunity fields and are set as the deal is
308
+ created; `amount` accepts a bare number or a written figure (`"$25,000/mo"`) and is
309
+ converted to Twenty's currency micros. Everything else is written as a custom field
310
+ in a follow-up call, so a rejected field never costs you the Opportunity itself.
311
+
312
+ A field routed to the Opportunity by a source that does not create one falls back to
313
+ the primary record, with a warning on the log.
314
+
315
+ ### Companies from email domains
316
+
317
+ **Intake never reads an employer out of an email address.** A company is created
318
+ only when the payload actually names one (`company`, `business`, `organization`) or
319
+ gives a domain of its own (`website`, `domain`, `url`). A lead from
320
+ `jane@gmail.com` with no company field creates a person and no company at all.
321
+
322
+ This is deliberate. Most small-business enquiries arrive from a consumer mailbox, so
323
+ a webhook that inferred companies from email domains would fill the Companies table
324
+ with mail hosts — a company called "gmail.com" with forty unrelated people filed
325
+ under it. If a mail provider arrives in a *website* field, Intake leaves it off the
326
+ company record and says so in the ingest log rather than storing it.
327
+
328
+ > **Twenty itself does infer companies this way, and it is on by default.** Every new
329
+ > workspace is seeded with a workflow called **"Create company when adding a new
330
+ > person"**, which fires on any person whose `emails` field is written — including
331
+ > people created through the REST and GraphQL APIs, and therefore including everything
332
+ > Intake writes. It extracts the domain from the person's email, creates a company for
333
+ > it if none matches, and then **updates the person's `companyId` to point at it** —
334
+ > overwriting the company Intake had already linked them to.
335
+ >
336
+ > The symptom is one lead producing two companies: the correct one on the Opportunity,
337
+ > and a domain-named one on the Person. Twenty's workflow skips a list of common
338
+ > personal domains (`gmail.com`, `yahoo.com`, `outlook.com`, `icloud.com` and similar),
339
+ > so it shows up on leads from business addresses.
340
+ >
341
+ > Nothing an app can send suppresses it — passing an explicit `companyId` does not.
342
+ > If you want Intake's company linkage to stand, open **Settings → Workflows**, find
343
+ > "Create company when adding a new person", and deactivate it.
344
+
345
+ ### Fields that cannot be written
346
+
347
+ A rule pointing at a field that does not exist — and is not `ext`-prefixed, so cannot
348
+ be auto-created — has its value routed to the note, with a warning naming the field.
349
+ It is not counted as matched. Create the field in Twenty first, or rename the rule's
350
+ `canonicalName` to use an `ext` prefix.
351
+
197
352
  ---
198
353
 
199
354
  ## Source configuration
@@ -207,6 +362,10 @@ Each `IntakeSource` record controls:
207
362
  | `createOpportunity` | `true` | Auto-create Opportunity per ingestion |
208
363
  | `opportunityNameTemplate` | `{{source}} — {{firstName}} {{lastName}}` | Supports `{{source}}`, `{{firstName}}`, `{{lastName}}`, `{{email}}`, `{{company}}` |
209
364
  | `status` | `ACTIVE` | Pause a source without deleting it |
365
+ | `mergePolicy` | — | Overrides `INTAKE_MERGE_POLICY` for this source |
366
+ | `honeypotField` | — | Overrides `INTAKE_HONEYPOT_FIELD` for this source |
367
+ | `expectedCadenceHours` | — | How long this source may go quiet before it counts as silent |
368
+ | `alertWebhookUrl` | — | Posted to once when this source falls silent |
210
369
 
211
370
  ---
212
371
 
@@ -222,6 +381,206 @@ Configurable from **Settings → Applications → Intake → Custom**:
222
381
  | `INTAKE_MAX_EXT_FIELDS` | `50` | Cap on custom fields per object |
223
382
  | `INTAKE_DEDUP_WINDOW_MINUTES` | `5` | Duplicate suppression window |
224
383
  | `INTAKE_REQUIRE_HMAC` | `false` | Enforce signed webhooks globally |
384
+ | `INTAKE_MERGE_POLICY` | `PRESERVE` | What an update does to a field that already has a value |
385
+ | `INTAKE_SPAM_FILTER_ENABLED` | `false` | Score payloads and quarantine at the threshold |
386
+ | `INTAKE_SPAM_SCORE_THRESHOLD` | `5` | Score at which a payload is held |
387
+ | `INTAKE_HONEYPOT_FIELD` | — | Name of a hidden form field that quarantines when filled |
388
+ | `INTAKE_RAW_PAYLOAD_RETENTION` | `FULL` | `FULL` keeps payloads for replay; `NONE` keeps none |
389
+ | `INTAKE_RAW_PAYLOAD_MAX_BYTES` | `65000` | Largest payload stored for replay |
390
+ | `INTAKE_REPLAY_MAX_BATCH` | `50` | Cap on one bulk replay (hard ceiling 500) |
391
+
392
+ ---
393
+
394
+ ## Updating existing records
395
+
396
+ When a payload matches a contact or company that already exists, `INTAKE_MERGE_POLICY`
397
+ decides what happens to fields that already hold a value.
398
+
399
+ | Policy | Behaviour |
400
+ |---|---|
401
+ | `PRESERVE` *(default)* | Fills fields that are empty, leaves everything else as it is |
402
+ | `NEWEST_WINS` | The incoming payload overwrites — how versions before 0.4.0 behaved |
403
+
404
+ Under both policies a **blank incoming value never overwrites anything**. An absent
405
+ field means the sender had nothing to say about it, not that it should be cleared.
406
+
407
+ `PRESERVE` is the default because the alternative loses data with no record of what
408
+ was there. A returning enquiry typed in lowercase should not replace a name a
409
+ salesperson corrected by hand, and nothing in a CRM undoes a field a webhook
410
+ overwrote at 3am.
411
+
412
+ Override it per source with the source's `mergePolicy`, or per field with a rule's
413
+ `mergeStrategy` — useful for genuinely volatile attributes:
414
+
415
+ | inputPattern | canonicalName | mergeStrategy |
416
+ |---|---|---|
417
+ | `lead_score` | `extLeadScore` | `NEWEST_WINS` |
418
+
419
+ The response body reports what the policy did, per object and per field, under
420
+ `mergeDecisions`.
421
+
422
+ **Upgrading from 0.3.0 and want the old behaviour?** Set `INTAKE_MERGE_POLICY=NEWEST_WINS`.
423
+
424
+ > **If a source exists to refresh data, `PRESERVE` will stop it refreshing.**
425
+ > A pipeline that re-scans a business every week and sends back an updated rating,
426
+ > review count or score writes those values once and then never again, because
427
+ > under `PRESERVE` the field already holds a value. This is the one case where the
428
+ > new default is the wrong one. Fix it at whichever scope fits:
429
+ >
430
+ > - the whole source is a refresher → set its `mergePolicy` to `NEWEST_WINS`
431
+ > - only some fields change → give those rules `mergeStrategy: NEWEST_WINS`
432
+ > - every source is a refresher → set `INTAKE_MERGE_POLICY=NEWEST_WINS`
433
+ >
434
+ > Check `mergeDecisions` in the response, or the `Kept the existing …` warnings on
435
+ > the log, to see whether this is happening to you.
436
+
437
+ ---
438
+
439
+ ## Not sending the same lead twice
440
+
441
+ Send an `Idempotency-Key` header (or an `idempotencyKey` field in the body) and a
442
+ repeat of that key resolves to the record made the first time, instead of creating a
443
+ second one:
444
+
445
+ ```bash
446
+ curl -X POST https://your-crm.com/s/intake/contact-form \
447
+ -H "Content-Type: application/json" \
448
+ -H "Idempotency-Key: submission-8f21c9" \
449
+ -d '{"email":"jane@acme.com"}'
450
+ ```
451
+
452
+ Unlike the content-hash deduplication — which only looks back
453
+ `INTAKE_DEDUP_WINDOW_MINUTES` — an idempotency key has **no time limit**. A
454
+ double-tapped submit button, a client retrying after a timeout and a queue
455
+ redelivering an hour later all resolve to the same record.
456
+
457
+ A key whose only previous use was quarantined or discarded is treated as unused, so
458
+ a released payload is not blocked by its own earlier attempt.
459
+
460
+ ---
461
+
462
+ ## Spam quarantine
463
+
464
+ Off by default. Two mechanisms, and they work independently.
465
+
466
+ **Honeypot** — set `INTAKE_HONEYPOT_FIELD` (or a source's `honeypotField`) to the
467
+ name of a form field hidden from people by CSS. Any payload arriving with it filled
468
+ was filled by a script, and is quarantined immediately. No false positives, so this
469
+ works whether or not scoring is enabled.
470
+
471
+ **Scoring** — set `INTAKE_SPAM_FILTER_ENABLED=true`. Each signal is worth points
472
+ rather than a verdict: a URL in a name field, a disposable or undeliverable email
473
+ domain, a link blast in the message, one long string pasted into every box, a
474
+ placeholder phone number. A payload is held when the total reaches
475
+ `INTAKE_SPAM_SCORE_THRESHOLD` (default `5`), so no single signal is enough on its own
476
+ — a genuine lead writing from a throwaway address still gets through.
477
+
478
+ Signals that would score *people* rather than behaviour are deliberately absent.
479
+ Non-Latin characters in a name and industry words like "SEO" carry no penalty.
480
+
481
+ A quarantined payload creates **no Person, Company, Opportunity or Note**. It is
482
+ recorded as an `IntakeLog` with status `QUARANTINED`, its score and its reasons, and
483
+ the webhook answers `202` — telling a bot which attempts were caught only teaches it
484
+ what to change, and a real person should not see a failure on a form that in fact
485
+ went through.
486
+
487
+ ```bash
488
+ # What is being held, and why
489
+ curl https://your-crm.com/s/intake/quarantine -H "Authorization: Bearer $KEY"
490
+
491
+ # Let one through — the filter is overruled, the score is still recorded
492
+ curl -X POST https://your-crm.com/s/intake/quarantine/$LOG_ID/release -H "Authorization: Bearer $KEY"
493
+
494
+ # Mark one as junk; add {"purgePayload":true} to drop the stored body
495
+ curl -X POST https://your-crm.com/s/intake/quarantine/$LOG_ID/discard -H "Authorization: Bearer $KEY"
496
+ ```
497
+
498
+ Turn scoring on only after watching the `spamScore` on a few days of real logs.
499
+
500
+ ---
501
+
502
+ ## Replay: applying a mapping you added too late
503
+
504
+ Every payload is stored on its log, so a rule written after the fact can be applied
505
+ to everything already received.
506
+
507
+ ```bash
508
+ # One log, through the rules as they are now
509
+ curl -X POST https://your-crm.com/s/intake/logs/$LOG_ID/replay -H "Authorization: Bearer $KEY"
510
+
511
+ # A batch — see what would be touched first
512
+ curl -X POST https://your-crm.com/s/intake/replay \
513
+ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
514
+ -d '{"sourceSlug":"contact-form","since":"2026-08-01T00:00:00Z","dryRun":true}'
515
+
516
+ # Then run it
517
+ curl -X POST https://your-crm.com/s/intake/replay \
518
+ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
519
+ -d '{"sourceSlug":"contact-form","since":"2026-08-01T00:00:00Z","limit":50}'
520
+ ```
521
+
522
+ Replay accepts logs that already **succeeded** — that is the point, since the
523
+ ingestion worked and only the mapping was missing. Retry is the narrower operation
524
+ and still refuses a successful log.
525
+
526
+ Replays run one at a time and are capped by `INTAKE_REPLAY_MAX_BATCH`, because each
527
+ one writes to the CRM. Each new log records `replayOfLogId`, so a re-mapped record
528
+ traces back to the payload it came from.
529
+
530
+ ### What is stored, and what is not
531
+
532
+ `INTAKE_RAW_PAYLOAD_RETENTION=FULL` (the default) keeps each payload as sent, which
533
+ is what retry and replay run from. `NONE` keeps nothing and disables both.
534
+
535
+ Under either setting, **credentials are never stored** — keys containing `password`,
536
+ `token`, `secret`, `apikey`, `authorization`, `cvv`, `card`, `ssn` and similar are
537
+ replaced with `[redacted]` before the payload is written.
538
+
539
+ Everything else the sender submitted **is** kept, including names, emails and phone
540
+ numbers. It lives on the `IntakeLog` object under your workspace's own access
541
+ control and is readable by anyone who can read that object. Payloads over
542
+ `INTAKE_RAW_PAYLOAD_MAX_BYTES` are ingested normally but not stored — a truncated
543
+ payload cannot be parsed, so it is dropped rather than half-kept, and the log says so.
544
+
545
+ ---
546
+
547
+ ## Knowing when a source goes quiet
548
+
549
+ The failure nobody notices is the one that produces no error: a form that breaks in
550
+ February and is found in August, with nothing but absent leads as evidence.
551
+
552
+ Give a source an `expectedCadenceHours` and it becomes monitored. Sources without
553
+ one are never flagged — silence is only a fault where traffic was expected.
554
+
555
+ ```bash
556
+ # Evaluate every source; run this on whatever timer you already have
557
+ curl -X POST https://your-crm.com/s/intake/sources/check-silence -H "Authorization: Bearer $KEY"
558
+ ```
559
+
560
+ The check records `healthStatus` (`HEALTHY`, `SILENT`, `NEVER_RECEIVED`) and
561
+ `silentSince` on each source, and posts once to the source's `alertWebhookUrl` on
562
+ the transition into silence — once, not on every check. The body carries a `text`
563
+ key, so Slack, Discord and Teams incoming webhook URLs work unchanged.
564
+
565
+ `GET /s/intake/health` also reports silent sources. It still returns `200` and the
566
+ same `status` and `timestamp` keys it always did, so existing monitors are
567
+ unaffected. Point a monitor at `/s/intake/health?strict=true` to get a `503` when a
568
+ source has fallen silent.
569
+
570
+ ---
571
+
572
+ ## Asking the app what it accepts
573
+
574
+ ```bash
575
+ curl https://your-crm.com/s/intake/contract -H "Authorization: Bearer $KEY"
576
+ ```
577
+
578
+ One call returns every endpoint, the built-in field map grouped by destination, the
579
+ custom fields that currently exist on each object, the active rules, every
580
+ registered source and the settings in force — so an integrator or an agent can learn
581
+ the contract without reading source or introspecting Twenty's metadata API.
582
+
583
+ Signing secrets never appear; a source reports only whether it requires a signature.
225
584
 
226
585
  ---
227
586
 
@@ -249,10 +608,27 @@ Sources without a secret accept unsigned requests — useful for internal tools.
249
608
  | Method | Path | Auth | Description |
250
609
  |---|---|---|---|
251
610
  | `POST` | `/s/intake/:slug` | HMAC or open | Ingest a payload |
252
- | `POST` | `/s/intake/:slug/test` | None | Dry-run — preview without writing |
253
- | `POST` | `/s/intake/logs/:logId/retry` | API key | Retry a failed ingestion |
254
- | `GET` | `/s/intake/health` | None | Health check |
611
+ | `POST` | `/s/intake/:slug/test` | None | Dry-run — structured diff, writes nothing |
612
+ | `GET` | `/s/intake/health` | None | Health check, plus silent sources |
613
+ | `GET` | `/s/intake/contract` | API key | What the app accepts and how it is configured |
255
614
  | `POST` | `/s/intake/sources/register` | API key | Register a new source |
615
+ | `POST` | `/s/intake/sources/check-silence` | API key | Check every source against its cadence |
616
+ | `POST` | `/s/intake/logs/:logId/retry` | API key | Retry a **failed** ingestion |
617
+ | `POST` | `/s/intake/logs/:logId/replay` | API key | Re-run **any** stored payload through current rules |
618
+ | `POST` | `/s/intake/replay` | API key | Bulk replay a selection of logs |
619
+ | `GET` | `/s/intake/quarantine` | API key | List held payloads and why |
620
+ | `POST` | `/s/intake/quarantine/:logId/release` | API key | Ingest a held payload |
621
+ | `POST` | `/s/intake/quarantine/:logId/discard` | API key | Mark a held payload as junk |
622
+
623
+ ### Response codes
624
+
625
+ | Code | Meaning |
626
+ |---|---|
627
+ | `200` | Ingested, or a duplicate resolved to the original record |
628
+ | `202` | Held for review by the spam filter — nothing was created |
629
+ | `401` | Signature missing or invalid |
630
+ | `404` | No source with that slug |
631
+ | `423` | Source is paused |
256
632
 
257
633
  ---
258
634