WRF Drupal API — NetSuite integration
Run the bundled script. It works through the same ladder a human would, in the order that isolates faults fastest, and stops at the first thing that is actually broken.
export WRF_TEST_CLIENT_ID='…'
export WRF_TEST_CLIENT_SECRET='…'
./health-check.sh test
Only bash and curl are required. Credentials are read from the environment so
they never enter shell history. Add --write to also exercise a
real create and update; on production that additionally needs
--force-prod, because the probe creates a node that the API
cannot delete afterwards.
GET /oauth/jwks and POST /oauth/token both returned
403 with an HTML challenge page on
e3test.waterrf.org and www.waterrf.org, from an
unallowlisted network, with every User-Agent tried.
This matters more than any token question, because a WAF 403 and a
Drupal 403 are the same status code with unrelated fixes — and the
WAF one is not yours to fix.
| Cloudflare challenge | Drupal denial | |
|---|---|---|
| Headers (decisive) | cf-mitigated: challenge + server: cloudflare | no cf-mitigated; server: nginx |
| Body | HTML — <title>Just a moment...</title> | JSON {"message": "…"} — or Drupal's HTML access-denied page |
| Affected paths | anything, including /oauth/token | only authenticated paths |
| Root cause | caller's IP is not allowlisted at the edge | permissions, scope, or role |
| Fix owner | Cloudflare zone admin — not Acquia Support | Drupal administrator |
GET /oauth/userinfo without a token returns 403 HTML
straight from Drupal, no WAF involved. Only the cf-mitigated
header settles it, so always capture headers with curl -D -.
SuiteScript's N/https cannot execute JavaScript, so
it can never solve a challenge. If NetSuite's calls are being challenged, the
integration is blocked at the edge and credential rotation will not help. The
paths that need a WAF skip rule are:
/api* (the on-site Swagger UI page)
/openapi/* (the spec that page loads)
/oauth/*
/.well-known/jwks.json (/oauth/jwks redirects here)
/node
/node/*
/taxonomy/term
/taxonomy/term/*
/entity/node_type/*
/api* will not work.
/api is a static Swagger UI page in the docroot. Everything it
does lives elsewhere: it loads its spec from /openapi/rest
and calls /oauth/token, /node and friends. Verified
2026-09-01: nothing is served under /api/ except the page itself —
/api/node?_format=json returns Drupal's
404 No route found. Use the full list above.
Full remediation runbook → Who owns the fix, which Cloudflare control to change, the exact rule expression, and an interim path that works today.
Check it in one command:
curl -sSL -D - -o /dev/null 'https://e3test.waterrf.org/oauth/jwks' | grep -iE 'cf-mitigated|^HTTP'
cf-mitigated: challenge present → edge problem, stop debugging tokens.
Absent, with HTTP/2 200 → the edge is clear and everything below applies.
Ownership matters here: Cloudflare is the authoritative DNS provider and
reverse proxy for waterrf.org, so this is WRF's own
Cloudflare zone. Acquia Support cannot fix it — the change is
made by whoever administers that zone.
The zone admin cannot find the event without the ray ID. Collect all four:
# Ray ID, status, and the cf-mitigated header
curl -sS -D - -o /dev/null 'https://e3test.waterrf.org/oauth/jwks' \
| grep -iE '^HTTP|^cf-ray|^cf-mitigated|^server'
# The egress IP Cloudflare actually saw
curl -sS https://api.ipify.org; echo
# UTC timestamp, to match the Security Events log
date -u '+%Y-%m-%dT%H:%M:%SZ'
From NetSuite, the egress IP is not your workstation's — it is the NetSuite data centre's. Get it from the failing SuiteScript's log or from your NetSuite administrator.
Do not guess at the fix. In the Cloudflare dashboard for the
waterrf.org zone: Security → Events, filter by
Ray ID using the cf-ray value from step 1. The event row
names the service that acted, and that determines which control to change:
| Service in the event | What to change |
|---|---|
| Bot Fight Mode / Super Bot Fight Mode | Most likely cause for a well-formed API client. Skip it for the API paths (step 4), or set “Definitely automated” to Allow. |
| Managed Rules (WAF) | Skip the managed ruleset for the API paths, or disable the specific rule ID the event names. |
| Security Level / Browser Integrity Check | Skip via a configuration rule on the API paths. If the zone is on “I'm Under Attack”, that alone challenges everything. |
| Custom rules | An existing rule is matching. Amend it rather than adding another. |
| Rate limiting | Raise the limit or exclude the API paths; a sync burst looks like abuse. |
| Option | Good for | Cost |
|---|---|---|
| A. WAF custom rule, Skip action, scoped to path (+ optional IP list) | The recommended default. Precise: only the API paths lose bot checks. | One rule to maintain. |
| B. IP Access Rule → Allow | Fastest unblock. Bypasses Security Level, Bot Fight Mode, and IP reputation. | Zone-wide — applies to every path, not just the API. Does not bypass Managed Rules or custom rules. |
| C. Shared secret header, or mTLS / Cloudflare Access service token | When NetSuite's egress ranges are too broad, unknown, or change often. | More setup; NetSuite must send the extra header or client certificate. |
Security → WAF → Custom rules → Create rule. Name it something a future admin will understand, e.g. Skip bot checks for NetSuite API. Expression:
(http.host in {"www.waterrf.org" "e3test.waterrf.org" "sandbox.waterrf.org"})
and (
starts_with(http.request.uri.path, "/api")
or starts_with(http.request.uri.path, "/openapi/")
or starts_with(http.request.uri.path, "/oauth/")
or http.request.uri.path eq "/.well-known/jwks.json"
or http.request.uri.path eq "/node"
or starts_with(http.request.uri.path, "/node/")
or starts_with(http.request.uri.path, "/taxonomy/term")
or starts_with(http.request.uri.path, "/entity/node_type/")
)
Ready-to-apply versions of this rule — dashboard, Cloudflare API, and
Terraform — are in cloudflare/ in this repo, together with the
verified table of which paths sit under /api* and which do not.
Action Skip, then tick the components the step 2 event named — typically Super Bot Fight Mode, Managed Rules, and Browser Integrity Check. Place the rule first in the custom-rules order, or a rule above it will still challenge.
and ip.src in $netsuite_egress is tighter, and right if
only NetSuite calls the API. But the Swagger console's requests come
from each engineer's own browser, not from the docs host — so an
IP-scoped rule blocks browser testing unless every engineer's egress is in the
list too.
Using an IP list: Manage Account → Configurations → Lists,
create netsuite_egress, then reference it as
$netsuite_egress. NetSuite's outbound ranges vary by account data
centre and change over time — get the authoritative list from your NetSuite
administrator, not from a blog post, and set a reminder to re-verify it.
./health-check.sh test
Check 1 should flip from FAIL … Blocked by the Cloudflare WAF to:
1. Reachability — GET /oauth/jwks (no credentials)
PASS Site up, routing intact, OAuth key material deployed.
Then confirm the header is gone — no cf-mitigated should appear:
curl -sS -D - -o /dev/null 'https://e3test.waterrf.org/oauth/jwks' | grep -i 'cf-mitigated' || echo "clear"
Cloudflare rule changes propagate in seconds, but a cached challenge cookie can mask success in a browser — retest in a private window.
Cloudflare is not in front of the Acquia origin hostnames, so they answer
normally while the public hostnames are being challenged. Verified 2026-09-01
against the staging origin: /.well-known/jwks.json returned
200 with real keys, and /oauth/token returned a
proper OAuth error for bad credentials — so the whole auth path is testable.
./health-check.sh origin # staging, Cloudflare bypassed
# or directly
curl -sS -X POST 'https://wrfsite1stg.prod.acquia-sites.com/oauth/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=client_credentials' \
-d "client_id=$WRF_TEST_CLIENT_ID" \
-d "client_secret=$WRF_TEST_CLIENT_SECRET" \
-d 'scope=default'
wrfsite1.prod.acquia-sites.com) — it redirects to a
www. host that does not resolve.
If the zone admin will not skip bot checks on those paths, option C is the fallback: NetSuite sends a shared secret header and the rule matches on it instead of on an IP.
any(http.request.headers["x-wrf-integration"][*] eq "<long-random-value>")
Weaker than mTLS — the value appears in Cloudflare logs — so rotate it on a
schedule and keep it out of tickets. Cloudflare Access service tokens
(CF-Access-Client-Id / CF-Access-Client-Secret) are
the hardened version of the same idea.
| Response | What it actually means | Fix |
|---|---|---|
| 403 HTML body | Cloudflare challenge. Drupal never saw the request. | Allowlist NetSuite egress IPs at the edge. Not fixable from NetSuite. |
| 401 | Missing, malformed, or expired bearer token. Tokens live 300 s. | If it worked minutes ago, the token expired — request a new one. Do not rotate credentials yet. |
| 403 JSON body | Token valid; the user behind it lacks permission. | Confirm administrator in GET /oauth/debug; confirm scope=default. |
| 406 empty body | ?_format=json was omitted. Looks like a network fault; isn't. |
Append ?_format=json to the URL. An Accept header does not substitute. |
| 415 | Content-Type: application/json missing on a write. |
Set the header explicitly. https.request() does not add it for you. |
| 422 | Body reached Drupal and failed entity validation. message names the field. |
Usually a missing type/vid, a bare scalar instead of [{"value": …}], a dead target_id, or a value outside an allowed list. |
400 unsupported_grant_type |
The token request body was JSON. | Send application/x-www-form-urlencoded. |
401 invalid_client |
Wrong credentials — very often the other environment's. | Test and prod consumers are distinct. Verify the pair before rotating. |
500 on /oauth/token |
OAuth key pair missing or unreadable on the server. | Server-side. Escalate; nothing to change in NetSuite. |
404 on /oauth/* |
simple_oauth not enabled or code not deployed. |
Escalate — a deployment problem, not a client one. |
| no response / timeout | DNS, TLS, or egress firewall. | Confirm the host resolves and outbound HTTPS from NetSuite is permitted. |
Access tokens are configured to expire after 300 seconds. This
is the single most common cause of an integration that intermittently returns
401. A scheduled script that fetches one token and then works
through a long batch will cross the boundary mid-run.
401 and retry the call once.
Never cache a token in a script parameter or between scheduled executions.
It cannot. The default scope has refresh_token
disabled and only client_credentials enabled, so no refresh token
is ever issued. Re-authenticate instead — it is a single cheap call.
Check which environment you are pointed at. Test uses NetSuite sandbox
9336228-SB1, production uses 9336228, and the Drupal
consumers are separate records with separate secrets. A prod secret against
test fails exactly like a revoked secret.
The token is fine; content permissions are not. The default scope
is role-bound to administrator, so the consumer's user must hold
that role. Confirm with GET /oauth/debug?_format=json — if
roles does not list administrator, that is the bug.
Updates address nodes by Drupal nid, not by
field_netsuite_id. Persist the nid returned by
POST /node on the NetSuite record and use it for later
PATCH calls. There is no upsert-by-external-id behaviour.
Each rung narrows the fault. Run them in order; the first failure is the one
worth investigating. health-check.sh automates all of this.
curl -sSL -D - -o /dev/null 'https://e3test.waterrf.org/oauth/jwks' | grep -iE '^HTTP|cf-mitigated'
# want: HTTP/2 301 then HTTP/2 200 (this path redirects to /.well-known/jwks.json)
# bad: HTTP/2 403 with cf-mitigated: challenge
# Use -L. Without it a healthy site looks like a bare 301 "failure".
TOKEN=$(curl -sS -X POST 'https://e3test.waterrf.org/oauth/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d "grant_type=client_credentials" \
-d "client_id=$WRF_TEST_CLIENT_ID" \
-d "client_secret=$WRF_TEST_CLIENT_SECRET" \
-d 'scope=default' | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
echo "${TOKEN:0:24}…"
curl -sS 'https://e3test.waterrf.org/oauth/userinfo' -H "Authorization: Bearer $TOKEN"
curl -sS 'https://e3test.waterrf.org/oauth/debug?_format=json' -H "Authorization: Bearer $TOKEN"
# want: "roles" includes "administrator"
# 403 here only means the debug permission is missing — harmless for the integration
curl -sS -o /dev/null -w '%{http_code}\n' \
'https://e3test.waterrf.org/entity/node_type/resource?_format=json' \
-H "Authorization: Bearer $TOKEN"
# 200 = the request reached Drupal and REST works.
# It does NOT prove your token is valid: this resource is readable
# ANONYMOUSLY (verified 2026-09-01). Step 3 is the authentication proof.
# But: userinfo 200 + write 403 does isolate the fault to content permissions.
_format is mandatory
curl -sS -o /dev/null -w '%{http_code}\n' \
'https://e3test.waterrf.org/entity/node_type/resource' \
-H "Authorization: Bearer $TOKEN"
# 406 is the correct answer here. Getting 406 elsewhere means you dropped the parameter.
curl -sS -X POST 'https://e3test.waterrf.org/node?_format=json' \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"type":[{"target_id":"resource"}],
"title":[{"value":"ZZZ API health check"}],
"status":[{"value":false}]}'
DELETE is not enabled on the node resource, so the node has to
be removed by hand at /node/<nid>/delete. Do this on test.
| Question | Where |
|---|---|
| Consumer client IDs and secrets | /admin/config/services/consumer |
| Token lifetime, key paths, scope provider | /admin/config/people/simple_oauth |
| Which scope maps to which role | /admin/config/people/simple_oauth/oauth2_scope/dynamic |
| Live tokens | /admin/config/people/simple_oauth/oauth2_token |
| Which methods and auth each resource allows | /admin/config/services/rest |
| NetSuite credentials and endpoints in effect | /admin/config/wrf_netsuite/verify |
| Service account that owns REST-created nodes | /admin/config/wrf_netsuite |
| What the integration actually wrote | Drupal log, channel wrf_netsuite — /admin/reports/dblog |
| Per-node audit trail | Revision log: “Automated sync from NetSuite via REST API” |
<i> and <b> in
field_description, field_abstract,
body, and field_access_only_body are rewritten to
<em> and <strong>. A diff between what
NetSuite sent and what Drupal stored is expected, not a bug.
uid in the payload is discarded. Requests carrying a
Bearer header have owner and revision user forced to the
configured service account, so “wrong author” is not a symptom worth chasing.
Include all of this — without it the first reply will just ask for it:
test or prod) and the exact URL, including the query string.403 from a Drupal 403.message field usually contains the answer.cf-ray header if present — Cloudflare support needs it../health-check.sh <env>.