Troubleshooting & health checks

WRF Drupal API — NetSuite integration

Start here

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.

Is it the WAF or is it Drupal?

Verified 2026-09-01: Cloudflare challenges non-browser clients on both environments. 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 challengeDrupal denial
Headers (decisive)cf-mitigated: challenge + server: cloudflareno cf-mitigated; server: nginx
BodyHTML — <title>Just a moment...</title>JSON {"message": "…"}or Drupal's HTML access-denied page
Affected pathsanything, including /oauth/tokenonly authenticated paths
Root causecaller's IP is not allowlisted at the edgepermissions, scope, or role
Fix ownerCloudflare zone admin — not Acquia SupportDrupal administrator
Do not use “HTML body means WAF” as your test. Drupal serves an HTML access-denied page too — verified 2026-09-01, 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/*
Excluding only /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.

Unblocking a Cloudflare 403 — runbook

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.

Need to keep working right now? Jump to step 6. The Acquia origin is not behind Cloudflare and answers normally, so you can validate credentials and payloads while the WAF change is pending.

Step 1 — Capture the evidence engineer · 2 min

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.

Step 2 — Identify which Cloudflare feature fired zone admin · 5 min

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 eventWhat to change
Bot Fight Mode / Super Bot Fight ModeMost 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 CheckSkip via a configuration rule on the API paths. If the zone is on “I'm Under Attack”, that alone challenges everything.
Custom rulesAn existing rule is matching. Amend it rather than adding another.
Rate limitingRaise the limit or exclude the API paths; a sync burst looks like abuse.

Step 3 — Choose the allow mechanism decision

OptionGood forCost
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.

Step 4 — Create the rule zone admin · 10 min

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.

Scope by IP or by path? It depends who has to reach it. Adding 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.

Practical split: skip bot checks by path (so both NetSuite and engineers work), and compensate with a rate-limiting rule on those same paths. Drupal's OAuth2 remains the actual authorization gate — the paths are not left unprotected, they just stop being bot-challenged.

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.

Step 5 — Verify engineer · 1 min

./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.

Step 6 — Interim: work via the Acquia origin engineer · now

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'
Diagnosis only — never the integration's real endpoint. It skips the WAF and the CDN, so it proves your credentials and payloads are correct but says nothing about whether NetSuite can reach the public hostname. It also bypasses edge caching and rate limiting. Do not point a NetSuite script at it, and do not use the production origin (wrfsite1.prod.acquia-sites.com) — it redirects to a www. host that does not resolve.

Step 7 — If the rule cannot be changed

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.

Status code reference

ResponseWhat it actually meansFix
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.

Token problems

“It worked five minutes ago”

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.

Fetch the token immediately before the batch it authorises. For long batches, re-fetch on any 401 and retry the call once. Never cache a token in a script parameter or between scheduled executions.

“Refresh token doesn't work”

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.

“Credentials are right but I get invalid_client”

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.

“Writes return 403 but reads work”

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.

“Every sync creates duplicates instead of updating”

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.

Health check ladder

Each rung narrows the fault. Run them in order; the first failure is the one worth investigating. health-check.sh automates all of this.

  1. 1 — Is the edge letting me through? (no credentials)
    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".
  2. 2 — Can I get a token?
    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}…"
  3. 3 — Does Drupal accept the token? (the authentication proof)
    curl -sS 'https://e3test.waterrf.org/oauth/userinfo' -H "Authorization: Bearer $TOKEN"
  4. 4 — Does the token carry the right role?
    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
  5. 5 — Is REST serving JSON? (not an auth check)
    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.
  6. 6 — Control: prove _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.
  7. 7 — Can it write? (leaves an unpublished node behind)
    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.

Where to look on the Drupal side

QuestionWhere
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 wroteDrupal log, channel wrf_netsuite/admin/reports/dblog
Per-node audit trailRevision log: “Automated sync from NetSuite via REST API”
Expect your markup to come back changed. On save, <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.
Author fields are overwritten by design. Any 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.

What to send when escalating

Include all of this — without it the first reply will just ask for it:

Never paste a client secret or an access token into a ticket. A token is a live credential for its 300-second window, and tickets are archived indefinitely. Redact both.