Executive Summary: Why the Nightly Batch Score Is Often Too Slow

In SaaS and product-led growth (PLG) businesses, churn rarely arrives without warning. Falling API call volume, repeated authorization errors, spikes in data exports, and seat deallocations all leave footprints in your cloud data warehouse (CDW).

Many Customer Success (CS) and Revenue Operations teams still run churn models on a nightly schedule. Scores are computed overnight, synced to a CRM such as Salesforce or HubSpot through reverse ETL, and reviewed by Customer Success Managers (CSMs) the next morning. A high-risk signal that appears at 2:00 PM on Monday may not reach a human until Tuesday morning. For a high-value account, that delay can be the difference between a save and a cancellation.

You can shorten the loop by scoring inside the warehouse as data arrives and pushing only the accounts that cross a risk threshold to Slack. This article shows two patterns:

  • Snowflake: a `SNOWFLAKE.ML.CLASSIFICATION` model scores new rows from a stream. A triggered task calls Snowflake’s native webhook notifications to post to Slack.
  • BigQuery: a BigQuery ML boosted tree model scores accounts. A continuous query exports candidate events to Pub/Sub, and a small Cloud Run function scores them and posts to Slack.

The two warehouses differ in an important way. Snowflake’s classification model can be called directly from a task. BigQuery continuous queries currently can’t call `ML.PREDICT`, so the BigQuery design needs one extra hop. The sections below explain why.

```

BATCH MODEL (higher latency):

[User event] -> [Raw logs] -> [Nightly ETL] -> [Batch scoring] -> [CRM sync / dashboard] -> [CSM reviews next day]

EVENT-DRIVEN MODEL (latency measured in seconds to minutes):

[User event] -> [Streaming or micro-batch ingestion] -> [In-warehouse scoring] -> [Threshold filter] -> [Slack alert]

```

A note on naming: Snowflake’s classification feature is now documented under “ML Functions” in its AI & ML section, separate from the LLM-based Cortex AI Functions. It is a gradient boosting model trained with SQL, not a large language model. The Snowflake patterns below apply to that SQL-trained model.


Technical Foundations: In-Warehouse Predictive Modeling

Scoring next to the data avoids exporting customer telemetry to separate infrastructure. That reduces data movement and governance overhead, and keeps the model’s access rules aligned with your existing warehouse roles.

Option 1: Snowflake ML Classification

According to Snowflake’s documentation, the classification function is powered by a gradient boosting machine. It supports binary and multi-class problems, and the documentation lists customer churn prediction as a common use case. You can’t choose the algorithm or set model parameters manually.

#### 1. Prepare the training data

Feature columns must be string, numeric, Boolean, or `TIMESTAMP_NTZ`. Strings and Booleans are treated as categorical, and numerics as continuous. Every column that isn’t the target is treated as a feature, so leave identifiers such as `account_id` out of the training table or view. Otherwise the model will try to learn from them.

#### 2. Train the churn model

```

CREATE OR REPLACE SNOWFLAKE.ML.CLASSIFICATION churn_classifier_v1(

INPUT_DATA => SYSTEM$REFERENCE('TABLE', 'ANALYTICS.PROD.CUSTOMER_HEALTH_TRAINING'),

TARGET_COLNAME => 'HAS_CHURNED',

CONFIG_OBJECT => {'evaluate': TRUE, 'on_error': 'SKIP'}

);

```

Training requires an active warehouse. Snowflake recommends an X-Small standard warehouse for prototyping and a Medium Snowpark-optimized warehouse for larger datasets and production workloads. Models are immutable, so to update one you retrain it, typically with `CREATE OR REPLACE`. Classification models can’t be cloned, and cloning or replicating a database that contains one currently skips the model.

#### 3. Evaluate before you pick a threshold

Don’t guess the alert threshold. Evaluate the model and inspect threshold-level metrics:

```

CALL churn_classifier_v1!SHOW_EVALUATION_METRICS();

CALL churn_classifier_v1!SHOW_GLOBAL_EVALUATION_METRICS();

CALL churn_classifier_v1!SHOW_THRESHOLD_METRICS();

CALL churn_classifier_v1!SHOW_CONFUSION_MATRIX();

CALL churn_classifier_v1!SHOW_FEATURE_IMPORTANCE();

```

These methods report precision, recall, F1, AUC, a confusion matrix, and metrics at varying probability thresholds. Use them to choose a cutoff that balances missed churners against alert volume. Predicted probabilities from tree ensembles aren’t guaranteed to be calibrated, so a score of 0.85 isn’t literally an 85% chance of churn. Treat the cutoff as a ranking decision and validate it against historical outcomes.

#### 4. Run inference

Inference data must use the same feature names and types as training data. Extra columns are ignored, and an unseen categorical value isn’t an error. Snowflake’s documentation shows wildcard expansion in an object literal, `{*}`, as the way to pass features:

```

SELECT

account_id,

account_name,

arr,

churn_classifier_v1!PREDICT(INPUT_DATA => {*}) AS prediction

FROM ANALYTICS.PROD.LIVE_ACCOUNT_METRICS

WHERE account_tier = 'Enterprise';

```

The prediction is an object containing the predicted class, a `logs` field, and a probability for each class. Class labels appear as strings such as `"True"` and `"False"`:

```

{

"class": "True",

"logs": null,

"probability": {

"False": 0.1241,

"True": 0.8759

}

}

```

To read the churn probability, use `prediction['probability']['True']::FLOAT`.

The model’s `!PREDICT` method requires the model’s `mlconsumer` instance role, while evaluation methods require `mladmin`. Grant the narrower role to whichever role runs your alerting task.

Option 2: BigQuery ML Boosted Trees

BigQuery ML trains a boosted tree classifier through the XGBoost library with a `CREATE MODEL` statement. Boosted tree classifiers are used with `ML.PREDICT` for classification.

#### 1. Train the model

Use a 0/1 label so the probability array is easy to filter:

```

CREATE OR REPLACE MODEL `project_id.retention_ml.churn_xgb_model`

OPTIONS(

MODEL_TYPE = 'BOOSTED_TREE_CLASSIFIER',

INPUT_LABEL_COLS = ['has_churned'],

AUTO_CLASS_WEIGHTS = TRUE,

MAX_ITERATIONS = 50

) AS

SELECT

logins_last_7d,

seat_utilization_pct,

open_p1_tickets,

license_exports_30d,

has_churned -- INT64: 1 = churned, 0 = retained

FROM `project_id.retention_ml.training_dataset`;

```

Review the model with `ML.EVALUATE` before putting it in front of CSMs. Churn is usually an imbalanced problem, which is why `AUTO_CLASS_WEIGHTS` is a reasonable starting option.

#### 2. Score accounts

```

SELECT

account_id,

account_name,

arr,

p.prob AS churn_probability

FROM ML.PREDICT(

MODEL `project_id.retention_ml.churn_xgb_model`,

TABLE `project_id.retention_ml.streaming_account_metrics`

),

UNNEST(predicted_has_churned_probs) AS p

WHERE p.label = 1;

```

#### An important limitation: continuous queries can’t call `ML.PREDICT`

It’s tempting to run this scoring query as a BigQuery continuous query. Google’s documentation, last updated September 2026, lists the supported AI and ML functions in continuous queries. That list covers `AI.GENERATE`, `AI.GENERATE_TEXT`, `ML.UNDERSTAND_TEXT`, `ML.TRANSLATE`, and `ML.NORMALIZER`. It states that BigQuery ML functions outside the list can’t be used. `ML.PREDICT` isn’t on it. Check the current documentation and release notes before you build, since this may change.

What continuous queries do support makes them a good *trigger*:

  • They run SQL continuously over data arriving via the Storage Write API, batch loads, DML inserts, and other sources.
  • They can write results to a BigQuery table or export them to Pub/Sub, Bigtable, or Spanner.
  • Exporting to Pub/Sub requires running the query with a service account.

So the practical BigQuery design is a two-stage pipeline. A continuous query filters candidate accounts and publishes them to Pub/Sub, and a downstream function scores them with `ML.PREDICT` and posts to Slack.


Architectural Blueprint

Snowflake path

```

[Live metrics table] -> [Append-only stream] -> [Triggered task]

|

ML.CLASSIFICATION PREDICT + threshold + dedup

|

SYSTEM$SEND_SNOWFLAKE_NOTIFICATION -> Slack webhook

```

BigQuery path

```

[Storage Write API / streaming inserts] -> [Metrics table]

-> [Continuous query: filter candidates, EXPORT DATA to Pub/Sub]

-> [Cloud Run function: ML.PREDICT + threshold + dedup]

-> [Slack webhook]

```

Whichever path you choose, put the same controls in place:

  • Prefilter cheaply. Only score accounts that matter, such as enterprise tier or ARR above a business-defined line.
  • Deduplicate. Don’t alert the same account repeatedly within a cooldown window.
  • Make delivery idempotent. BigQuery’s documentation warns that continuous queries can occasionally reprocess data and produce duplicate output, so downstream systems must tolerate duplicates.

Step-by-Step Execution: Snowflake to Slack

Step 1: Store the Slack webhook as a secret

Snowflake supports webhook notification integrations for Slack, Microsoft Teams, PagerDuty, Jira, and ServiceNow. For Slack, create a generic-string secret holding only the secret portion of the webhook URL, meaning everything after `https://hooks.slack.com/services/`:

```

CREATE OR REPLACE SECRET my_secrets_db.my_secrets_schema.slack_webhook_secret

TYPE = GENERIC_STRING

SECRET_STRING = 'T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX';

```

Use a dedicated role for this setup rather than `ACCOUNTADMIN` where your organization’s policies allow it. Creating the integration requires `USAGE` on the secret, and sending notifications requires `READ` on the secret plus `USAGE` on its database and schema.

Step 2: Create the webhook notification integration

```

CREATE OR REPLACE NOTIFICATION INTEGRATION churn_slack_webhook_int

TYPE = WEBHOOK

ENABLED = TRUE

WEBHOOK_URL = 'https://hooks.slack.com/services/SNOWFLAKE_WEBHOOK_SECRET'

WEBHOOK_SECRET = my_secrets_db.my_secrets_schema.slack_webhook_secret

WEBHOOK_BODY_TEMPLATE = '{"text": "SNOWFLAKE_WEBHOOK_MESSAGE"}'

WEBHOOK_HEADERS = ('Content-Type' = 'application/json');

```

Send a test message before wiring in the model:

```

CALL SYSTEM$SEND_SNOWFLAKE_NOTIFICATION(

SNOWFLAKE.NOTIFICATION.TEXT_PLAIN(

SNOWFLAKE.NOTIFICATION.SANITIZE_WEBHOOK_CONTENT('Churn alert pipeline test')

),

SNOWFLAKE.NOTIFICATION.INTEGRATION('churn_slack_webhook_int')

);

```

`SANITIZE_WEBHOOK_CONTENT` removes template placeholders such as `SNOWFLAKE_WEBHOOK_SECRET` from the message. Because the template wraps the message in JSON, test with realistic account names, including ones containing quotation marks, so the payload stays valid.

Step 3: Create a stream and an alert log

```

CREATE OR REPLACE STREAM ANALYTICS.PROD.ACCOUNT_METRICS_STREAM

ON TABLE ANALYTICS.PROD.LIVE_ACCOUNT_METRICS

APPEND_ONLY = TRUE; -- use only if the table receives inserts, not updates

CREATE TABLE IF NOT EXISTS ANALYTICS.PROD.CHURN_ALERT_LOG (

account_id STRING,

churn_probability FLOAT,

alerted_at TIMESTAMP_LTZ DEFAULT CURRENT_TIMESTAMP()

);

```

Step 4: Score, filter, deduplicate, and notify

```

CREATE OR REPLACE PROCEDURE ANALYTICS.PROD.ROUTE_CHURN_ALERTS()

RETURNS STRING

LANGUAGE SQL

AS

$$

DECLARE

sent INTEGER DEFAULT 0;

BEGIN

-- Consuming the stream in this statement advances its offset.

CREATE OR REPLACE TEMPORARY TABLE scored_batch AS

WITH raw AS (

SELECT account_id, account_name, arr,

ANALYTICS.PROD.churn_classifier_v1!PREDICT(INPUT_DATA => {*}) AS prediction

FROM ANALYTICS.PROD.ACCOUNT_METRICS_STREAM

WHERE account_tier = 'Enterprise' AND arr >= 50000

)

SELECT account_id, account_name, arr,

prediction['probability']['True']::FLOAT AS churn_probability

FROM raw;

LET res RESULTSET := (

SELECT b.account_id, b.account_name, b.arr, b.churn_probability

FROM scored_batch b

WHERE b.churn_probability >= 0.80 -- choose from SHOW_THRESHOLD_METRICS

AND NOT EXISTS (

SELECT 1 FROM ANALYTICS.PROD.CHURN_ALERT_LOG l

WHERE l.account_id = b.account_id

AND l.alerted_at > DATEADD('hour', -24, CURRENT_TIMESTAMP())

)

);

FOR rec IN res DO

LET acct STRING := rec.account_id;

LET prob FLOAT := rec.churn_probability;

LET msg STRING := 'Churn risk ' || ROUND(rec.churn_probability * 100, 1) || '% for '

|| REPLACE(rec.account_name, '"', '') || ' (ARR ' || rec.arr || ')';

CALL SYSTEM$SEND_SNOWFLAKE_NOTIFICATION(

SNOWFLAKE.NOTIFICATION.TEXT_PLAIN(

SNOWFLAKE.NOTIFICATION.SANITIZE_WEBHOOK_CONTENT(:msg)

),

SNOWFLAKE.NOTIFICATION.INTEGRATION('churn_slack_webhook_int')

);

INSERT INTO ANALYTICS.PROD.CHURN_ALERT_LOG(account_id, churn_probability)

VALUES (:acct, :prob);

sent := sent + 1;

END FOR;

RETURN 'alerts sent: ' || sent;

END;

$$;

```

Adapt object names, privileges, and the dedup window to your environment, and test the procedure with the role that will own the task.

Step 5: Trigger it from the stream

Triggered tasks run when a stream has data, so you don’t need a fixed schedule. Omit the `SCHEDULE` parameter and use a `WHEN` clause:

```

CREATE OR REPLACE TASK ANALYTICS.PROD.CHURN_ALERT_TASK

WAREHOUSE = ML_WH

WHEN SYSTEM$STREAM_HAS_DATA('ANALYTICS.PROD.ACCOUNT_METRICS_STREAM')

AS

CALL ANALYTICS.PROD.ROUTE_CHURN_ALERTS();

ALTER TASK ANALYTICS.PROD.CHURN_ALERT_TASK RESUME;

```

According to Snowflake’s documentation, triggered tasks run at most every 30 seconds by default. You can lower that to 10 seconds with the `USER_TASK_MINIMUM_TRIGGER_INTERVAL_IN_SECONDS` parameter. They use no compute until triggered, and Snowflake runs only one instance of a task at a time. Your actual end-to-end latency also depends on how quickly events land in the source table, so measure it rather than assuming a number.

Optional: richer Slack messages with a Python UDF

The webhook notification integration sends a simple template. For a richer Block Kit layout with fields such as primary risk driver and account owner, call Slack from a Python UDF using an external access integration. The secret is exposed to the handler through the `_snowflake` module.

```

CREATE OR REPLACE SECRET ANALYTICS.PROD.slack_webhook_url

TYPE = GENERIC_STRING

SECRET_STRING = 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX';

CREATE OR REPLACE NETWORK RULE ANALYTICS.PROD.slack_egress

MODE = EGRESS

TYPE = HOST_PORT

VALUE_LIST = ('hooks.slack.com:443');

CREATE OR REPLACE EXTERNAL ACCESS INTEGRATION slack_eai

ALLOWED_NETWORK_RULES = (ANALYTICS.PROD.slack_egress)

ALLOWED_AUTHENTICATION_SECRETS = (ANALYTICS.PROD.slack_webhook_url)

ENABLED = TRUE;

CREATE OR REPLACE FUNCTION ANALYTICS.PROD.SEND_SLACK_CHURN_ALERT(

account_name STRING, arr NUMBER, churn_prob FLOAT,

risk_driver STRING, csm_owner STRING)

RETURNS STRING

LANGUAGE PYTHON

RUNTIME_VERSION = '3.12'

PACKAGES = ('requests')

HANDLER = 'send'

EXTERNAL_ACCESS_INTEGRATIONS = (slack_eai)

SECRETS = ('webhook' = ANALYTICS.PROD.slack_webhook_url)

AS

$$

import requests

import _snowflake

def send(account_name, arr, churn_prob, risk_driver, csm_owner):

url = _snowflake.get_generic_secret_string('webhook')

payload = {

"text": f"Churn risk: {account_name}",

"blocks": [

{"type": "header",

"text": {"type": "plain_text", "text": "High churn risk"}},

{"type": "section", "fields": [

{"type": "mrkdwn", "text": f"*Account:*\n{account_name}"},

{"type": "mrkdwn", "text": f"*ARR:*\n{arr:,.0f}"},

{"type": "mrkdwn", "text": f"*Risk score:*\n{churn_prob:.2f}"},

{"type": "mrkdwn", "text": f"*Top driver:*\n{risk_driver}"},

{"type": "mrkdwn", "text": f"*Owner:*\n{csm_owner}"},

]},

],

}

r = requests.post(url, json=payload, timeout=10)

return str(r.status_code)

$$;

```

Snowflake’s GA Python UDF runtimes currently include 3.10 through 3.13. Pick a version your account supports.

About interactive buttons: an incoming webhook can post Block Kit layouts, but it can’t receive button clicks. A “Claim Account” button needs a Slack app with interactivity enabled and a request URL that receives the click payload. If you want that, use a Slack app with a bot token and handle the callback in a small service. A plain incoming webhook is fine for one-way alerts.


Step-by-Step Execution: BigQuery to Slack

Step 1: Stream events into BigQuery

Continuous queries can process data written to standard BigQuery tables via the Storage Write API, batch loads, `INSERT` statements, Pub/Sub BigQuery subscriptions, Dataflow, and a few other methods. Land your telemetry or pre-aggregated health metrics in a standard table, for example `retention_ml.streaming_account_metrics`.

Step 2: Set up capacity for continuous queries

Continuous queries don’t run on on-demand pricing. Google’s documentation says they require:

  • An Enterprise or Enterprise Plus edition reservation.
  • A reservation assignment with the `CONTINUOUS` job type.
  • A service account if you export to Pub/Sub. A job started with a user account stops after two days, while a service-account job can run up to 150 days before it must be restarted.

Budget for this capacity as a fixed cost of the design. Slot autoscaling is recommended, and BigQuery requires a 10-slot threshold per continuous query job to admit it.

Step 3: Publish candidate accounts to Pub/Sub

Because scoring can’t happen inside the continuous query, use it to select accounts that deserve scoring and publish them as JSON. The exported column must be named `message`, and Pub/Sub topics with schemas aren’t supported as export targets.

```

EXPORT DATA OPTIONS (

format = 'CLOUD_PUBSUB',

uri = 'https://pubsub.googleapis.com/projects/PROJECT_ID/topics/churn-candidates'

) AS (

SELECT TO_JSON_STRING(STRUCT(

account_id,

account_name,

arr

)) AS message

FROM `project_id.retention_ml.streaming_account_metrics`

WHERE account_tier = 'Enterprise'

AND arr >= 50000

AND seat_utilization_pct < 40 -- cheap rule-based prefilter

);

```

Run this as a continuous query under a service account. Tune the prefilter so you score a manageable stream rather than every event.

Step 4: Score and notify from a Cloud Run function

A function triggered by the Pub/Sub topic runs `ML.PREDICT` for the candidate account and posts to Slack if the score clears your threshold:

```

import base64, json, os

import functions_framework

import requests

from google.cloud import bigquery

bq = bigquery.Client()

SQL = """

SELECT account_id, account_name, arr, p.prob AS churn_probability

FROM ML.PREDICT(

MODEL `project_id.retention_ml.churn_xgb_model`,

(SELECT *

FROM `project_id.retention_ml.streaming_account_metrics`

WHERE account_id = @account_id

ORDER BY event_ts DESC

LIMIT 1)

),

UNNEST(predicted_has_churned_probs) AS p

WHERE p.label = 1

"""

@functions_framework.cloud_event

def handle(cloud_event):

msg = json.loads(base64.b64decode(cloud_event.data["message"]["data"]))

job = bq.query(SQL, job_config=bigquery.QueryJobConfig(

query_parameters=[bigquery.ScalarQueryParameter(

"account_id", "STRING", msg["account_id"])]))

for row in job.result():

if row.churn_probability >= 0.80: # set from ML.EVALUATE results

requests.post(

os.environ["SLACK_WEBHOOK_URL"], # inject from Secret Manager

json={"text": f"Churn risk {row.churn_probability:.0%} for "

f"{row.account_name} (ARR {row.arr:,.0f})"},

timeout=10,

).raise_for_status()

```

Production hardening checklist:

  • Inject the webhook URL from Secret Manager rather than hard-coding it.
  • Record each alert (account, score, timestamp) in a table and skip accounts already alerted within your cooldown window. Pub/Sub delivery and continuous query reprocessing can both create duplicates.
  • Handle HTTP 429 responses from Slack. Slack rate-limits incoming webhooks, commonly cited as about one message per second per webhook with short bursts allowed, and returns a `Retry-After` header when you exceed limits.

Alternative: simpler micro-batch scoring

If you don’t need event-by-event latency, skip continuous queries and their reservation requirement. Run a frequent scheduled job or Cloud Run job that executes `ML.PREDICT` over recently arrived rows and posts the high-scoring accounts. It’s cheaper and simpler to operate, at the cost of latency tied to the schedule. BigQuery ML also supports exporting some models for serving elsewhere. Check which model types your chosen export path supports before committing to that design.


Design Considerations Before You Go Live

Choose thresholds with data. Use the evaluation outputs (`SHOW_THRESHOLD_METRICS` in Snowflake, `ML.EVALUATE` in BigQuery ML) to pick a cutoff that matches CS capacity. A model that fires 200 alerts a day will be ignored. One that fires a handful of well-targeted alerts will be acted on.

Guard against alert fatigue. Add cooldown windows, route by account owner or segment, and consider a daily digest for medium-risk accounts alongside immediate alerts for the highest-value ones.

Watch for data leakage and drift. Make sure training features only use information available at prediction time. Retrain regularly, since Snowflake models are immutable and must be replaced, and BigQuery models should be re-evaluated on recent data.

Keep sensitive data out of Slack. Alerts should carry an account name, a score, and a link to your CRM or dashboard, not raw usage logs or personal data.

Protect your secrets. Webhook URLs are bearer secrets. Anyone with the URL can post to the channel. Store them in Snowflake secrets or Google Secret Manager, and rotate them if exposed.

Measure latency yourself. The speed of the whole chain depends on ingestion method, warehouse or slot capacity, task trigger intervals, and Slack delivery. Instrument timestamps at each hop and report the real number.


Conclusion

Moving churn scoring from a nightly batch to an event-driven pipeline changes how quickly your team can respond to at-risk accounts. Snowflake’s stream, triggered task, and webhook notification features let you score and alert entirely from SQL. On BigQuery, the same outcome needs a continuous query that exports candidates to Pub/Sub plus a small function that runs `ML.PREDICT`, because `ML.PREDICT` isn’t currently supported inside continuous queries. In both cases, the model’s value depends on a carefully chosen threshold, deduplicated alerts, and a Slack message that tells the CSM what to do next.


References

  • Snowflake: Classification (Snowflake ML Functions) — <https://docs.snowflake.com/en/user-guide/ml-functions/classification>
  • Snowflake: CREATE SNOWFLAKE.ML.CLASSIFICATION — <https://docs.snowflake.com/en/sql-reference/classes/classification/commands/create-classification>
  • Snowflake: Triggered tasks — <https://docs.snowflake.com/en/user-guide/tasks-triggered>
  • Snowflake: Sending webhook notifications — <https://docs.snowflake.com/en/en/user-guide/notifications/webhook-notifications>
  • Snowflake: API reference for access to secrets — <https://docs.snowflake.com/developer-guide/external-network-access/secret-api-reference>
  • Snowflake: Creating Python UDFs — <https://docs.snowflake.com/developer-guide/udf/python/udf-python-creating>
  • Google Cloud: Introduction to BigQuery continuous queries — <https://docs.cloud.google.com/bigquery/docs/continuous-queries-introduction>
  • Google Cloud: Export data to Pub/Sub (reverse ETL) — <https://docs.cloud.google.com/bigquery/docs/export-to-pubsub>
  • Google Cloud: CREATE MODEL for boosted trees using XGBoost — <https://cloud.google.com/bigquery-ml/docs/reference/standard-sql/bigqueryml-syntax-create-boosted-tree>
  • Slack: Sending messages using incoming webhooks — <https://api.slack.com/messaging/webhooks>