Integrations / Cron pattern

Dynamic campaign pause via cron

Not every automation should be event-driven. Some checks are inherently periodic, like “is this campaign still healthy enough to keep sending invitations?” This recipe runs nightly, reads the campaign's acceptance funnel, and calls manage_outreach(action="pause") if the campaign is underperforming.

Why this is a poll, not a webhook

Aggregate metrics like a campaign's acceptance rate are not events; they are rolling aggregates. A webhook firing on every change to the rate would be loud and useless. A nightly poll is the right shape.

Crispy intentionally exposes the campaign and analytics surfaces for exactly this kind of cron-style logic. The webhook surface and these read surfaces compose: webhooks handle “something happened,” the read surfaces handle “what's the current state of the world.”

The shell version

Drop this in any cron-friendly environment (a Linux box, fly machine run, GitHub Actions on a schedule). Requires curl and jq.

#!/usr/bin/env bash
# pause-cold-campaign.sh — pause a Crispy campaign if its acceptance rate < 10%.
# Run nightly via cron, GitHub Actions, or any scheduler.

set -euo pipefail

API_KEY="${CRISPY_API_KEY:?Set CRISPY_API_KEY}"
CAMPAIGN_ID="${CAMPAIGN_ID:?Set CAMPAIGN_ID}"
THRESHOLD="${THRESHOLD:-0.10}"  # 10%

# The campaign 'state' slice returns a funnel with sent/accepted counts and a
# precomputed accept_rate (a float in [0,1]).
STATE=$(curl -sS \
  -H "Authorization: Bearer $API_KEY" \
  "https://crispy.sh/api/v1/campaigns/$CAMPAIGN_ID/state")

SENT=$(echo "$STATE" | jq -r '.state.data.funnel.sent // 0')
RATE=$(echo "$STATE" | jq -r '.state.data.funnel.accept_rate // 0')

if [ "$SENT" -lt 30 ]; then
  echo "Skipping: only $SENT invites sent so far (need >=30 for a stable rate)"
  exit 0
fi

# bash doesn't do floats; multiply by 100 and compare ints.
RATE_PCT=$(awk "BEGIN { printf \"%.0f\", $RATE * 100 }")
THRESHOLD_PCT=$(awk "BEGIN { printf \"%.0f\", $THRESHOLD * 100 }")

echo "Acceptance rate: ${RATE_PCT}% (threshold: ${THRESHOLD_PCT}%)"

if [ "$RATE_PCT" -lt "$THRESHOLD_PCT" ]; then
  echo "Pausing campaign $CAMPAIGN_ID"
  curl -sS -X POST \
    -H "Authorization: Bearer $API_KEY" \
    "https://crispy.sh/api/v1/campaigns/$CAMPAIGN_ID/outreach/pause"
fi

Add to crontab: 0 9 * * * /usr/local/bin/pause-cold-campaign.sh runs daily at 09:00.

The n8n version

Same logic, no shell box needed:

1. Schedule Trigger → daily at 09:00 UTC
2. HTTP Request (GET) → https://crispy.sh/api/v1/campaigns/{{$env.CAMPAIGN_ID}}/state
3. Code (JavaScript) → const f = items[0].json.state.data.funnel; return [{ json: { rate: f.accept_rate, sent: f.sent } }];
4. IF → {{ $json.rate < 0.10 && $json.sent >= 30 }}
5. HTTP Request (POST) → https://crispy.sh/api/v1/campaigns/{{$env.CAMPAIGN_ID}}/outreach/pause

Knobs to tune

  • Sample size guard. The shell script bails if fewer than 30 invitations have been sent so far. Without it, a slow start can pause your campaign on noise. Tune the floor based on your daily volume.
  • Threshold. 10% is a reasonable cold-outbound floor for senior ICPs. Tune up for warmer audiences, down for cold lists.
  • Cumulative vs rolling window. The campaign state funnel is cumulative over the campaign's lifetime. If you want a rolling account-wide window instead, read GET /api/v1/analytics?dimension=invitations&period=week (or period=month) and threshold on that.
  • Resume. Pair this with a separate nightly job that POSTs to /api/v1/campaigns/{id}/outreach/resume once the rate recovers, so you don't need a human to babysit the pause.

Related

For event-driven pauses (e.g. pause if campaign.guardian_paused fires), use the webhook surface directly. The cron pattern shown here is for thresholds that depend on rolling aggregates, not single events.