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, polls the analytics surface, and calls manage_outreach(pause) if the campaign is underperforming.

Why this is a poll, not a webhook

Aggregate metrics like “7-day acceptance rate” are not events; they are rolling windows. 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 analytics surface for exactly this kind of cron-style logic. The webhook surface and the analytics surface compose: webhooks handle “something happened,” analytics handles “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 7d 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%

ANALYTICS=$(curl -sS \
  -H "Authorization: Bearer $API_KEY" \
  "https://crispy.sh/api/v1/analytics?dimension=invitations&campaign_id=$CAMPAIGN_ID&days=7")

SENT=$(echo "$ANALYTICS" | jq -r '.totals.sent // 0')
ACCEPTED=$(echo "$ANALYTICS" | jq -r '.totals.accepted // 0')

if [ "$SENT" -lt 30 ]; then
  echo "Skipping: only $SENT invites in last 7 days (need >=30 for stable rate)"
  exit 0
fi

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

echo "7d 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" \
    -H "Content-Type: application/json" \
    "https://crispy.sh/api/v1/manage_outreach" \
    -d "{\"action\":\"pause\",\"campaign_id\":\"$CAMPAIGN_ID\",\"reason\":\"7d acceptance below ${THRESHOLD_PCT}%\"}"
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/analytics?dimension=invitations&campaign_id={{$env.CAMPAIGN_ID}}&days=7
3. Code (JavaScript) → const { totals } = items[0].json; const rate = totals.sent > 0 ? totals.accepted / totals.sent : 0; return [{ json: { rate, sent: totals.sent } }];
4. IF → {{ $json.rate < 0.10 && $json.sent >= 30 }}
5. HTTP Request (POST) → https://crispy.sh/api/v1/manage_outreach with body { "action": "pause", "campaign_id": "{{$env.CAMPAIGN_ID}}" }

Knobs to tune

  • Sample size guard. The shell script bails if fewer than 30 invitations were sent in the window. Without it, a slow week 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.
  • Window size. 7 days catches recent reality; 30 days smooths noise but reacts slowly. Pick the one that fits your iteration speed.
  • Resume. Pair this with a separate nightly job that calls manage_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.