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"
fiAdd 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/pauseKnobs 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
statefunnel is cumulative over the campaign's lifetime. If you want a rolling account-wide window instead, readGET /api/v1/analytics?dimension=invitations&period=week(orperiod=month) and threshold on that. - Resume. Pair this with a separate nightly job that POSTs to
/api/v1/campaigns/{id}/outreach/resumeonce 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.