gohighlevel

A Ruby SDK for the HighLevel (GoHighLevel) API.

  • Resource methods generated from the official OpenAPI spec — 40 apps, 700+ endpoints.
  • Runtime behaviour (auth resolution, OAuth refresh, webhook verification) hand-written and tested against the canonical docs.
  • Faraday 2 transport, Zeitwerk loader, immutable per-client configuration — no global mutable state.
  • Ruby 3.3+.

Installation

# Gemfile
gem "gohighlevel"
bundle add gohighlevel

The storage-backend gems (redis, activerecord, mongo) are not runtime dependencies — add whichever you need to your own Gemfile. The default in-memory store needs nothing.

Quickstart

require "gohighlevel"

client = HighLevel::Client.new(private_integration_token: "pit-xxxxxxxx")

# Every app is a resource on the client.
contacts = client.contacts.search_contacts_advanced(
  body: { locationId: "loc-123", pageLimit: 20 }
)

tasks = client.contacts.get_all_tasks(contact_id: "contact-456")

Resource method names are the spec's operationId in snake_case. Path parameters are required keyword arguments; query parameters are optional keyword arguments; a request body (when the endpoint declares one) is the body: keyword argument.

Authentication

HighLevel::Client.new accepts one of four credential modes. Configuration is immutable and per-instance.

# 1. Private Integration Token — simplest, always wins when present.
HighLevel::Client.new(private_integration_token: "pit-xxxx")

# 2. Agency access token.
HighLevel::Client.new(agency_access_token: "agency-xxxx")

# 3. Location access token.
HighLevel::Client.new(location_access_token: "loc-xxxx")

# 4. OAuth app credentials — required for the OAuth flows + 401 refresh.
HighLevel::Client.new(client_id: "xxxx", client_secret: "xxxx")

You can also pass a prebuilt HighLevel::Configuration:

config = HighLevel::Configuration.new(
  private_integration_token: "pit-xxxx",
  api_version: "2021-07-28",
  session_storage: HighLevel::Storage::Memory.new,
  instrumenter: ActiveSupport::Notifications
)
HighLevel::Client.new(config)

For OAuth-secured endpoints, the SDK resolves the right token per request from the operation's declared security requirements (Agency-Access, Location-Access, bearer, ...), falling back to session storage when no direct token is configured.

OAuth flows

client = HighLevel::Client.new(
  client_id: "xxxx",
  client_secret: "xxxx",
  redirect_uri: "https://your.app/oauth/callback"
)

# 1. Send the user here to authorize.
url = client.oauth.authorization_url(scope: "contacts.readonly contacts.write")

# 2. Exchange the code that comes back on your callback.
tokens = client.oauth.exchange_code(code: params[:code], user_type: "Location")

# 3. Refresh later.
tokens = client.oauth.refresh_token(refresh_token: stored_refresh, user_type: "Location")

# 4. Derive a location token from an agency token.
tokens = client.oauth.get_location_access_token(company_id: "co-1", location_id: "loc-1")

When a request returns 401, the SDK transparently attempts a refresh (using the session in storage), retries once, and — for location tokens — falls back to re-deriving from the company token. A second 401 is propagated, not looped.

Storage backends

Session storage holds OAuth tokens keyed by resource (company/location) id. HighLevel::Storage::Memory is the default and needs no configuration.

Backend When to use Extra gem
Storage::Memory Tests, single-process apps — (default)
Storage::ActiveRecord Rails apps with an existing database activerecord
Storage::Redis Multi-process workers sharing tokens redis
Storage::Mongo Parity with TS SDK consumers sharing a Mongo store mongo
# Redis
store = HighLevel::Storage::Redis.new(url: "redis://localhost:6379/0")
HighLevel::Client.new(client_id: "x", client_secret: "y", session_storage: store)

# ActiveRecord — once, in a migration:
HighLevel::Storage::ActiveRecord::Migration.create_table!(connection)
store = HighLevel::Storage::ActiveRecord.new

All backends satisfy the same contract (test/support/session_storage_contract.rb). Writing your own: subclass HighLevel::Storage::Base and implement the seven methods.

Webhook verification

HighLevel signs webhooks with its private key and publishes the public key — verification is asymmetric, not HMAC. Two schemes are supported: :rsa (signature on the x-wh-signature header) and :ed25519 (x-ghl-signature).

# Sinatra
post "/webhooks/highlevel" do
  body = request.body.read
  HighLevel::Webhooks.verify(
    payload: body,
    signature: request.env["HTTP_X_WH_SIGNATURE"],
    public_key: ENV.fetch("HIGHLEVEL_WEBHOOK_PUBLIC_KEY"),
    scheme: :rsa
  )
  # raises HighLevel::Webhooks::InvalidSignatureError if it doesn't verify
  process(JSON.parse(body))
  status 200
rescue HighLevel::Webhooks::InvalidSignatureError
  halt 401
end
# Rails controller
def highlevel
  HighLevel::Webhooks.verify(
    payload: request.raw_post,
    signature: request.headers["X-Wh-Signature"],
    public_key: Rails.application.credentials.highlevel_webhook_public_key
  )
  ProcessWebhookJob.perform_later(request.raw_post)
  head :ok
rescue HighLevel::Webhooks::InvalidSignatureError
  head :unauthorized
end

Pass the raw request body bytes — re-serializing a parsed JSON object changes the canonical form and breaks verification.

Pagination

The HighLevel API has no uniform pagination convention, so nothing is auto-paginated. HighLevel::Pagination is opt-in.

# A proc that routes pagination params wherever the endpoint wants them.
fetch = ->(**page) do
  client.contacts.search_contacts_advanced(body: { locationId: "loc-1" }.merge(page))
end

# Each page (raw response):
HighLevel::Pagination.each_page(fetch, cursor_field: :skip, items_field: "contacts") do |page|
  puts page["contacts"].size
end

# Each item, flattened across pages:
HighLevel::Pagination.each_item(fetch, cursor_field: :skip, items_field: "contacts") do |contact|
  puts contact["id"]
end

# Without a block, you get an Enumerator:
enum = HighLevel::Pagination.each_item(fetch, cursor_field: :skip, items_field: "contacts")
enum.lazy.select { |c| c["type"] == "lead" }.first(10)

cursor_field is the parameter the endpoint advances on (:offset, :skip, ...). limit_field defaults to :limit; page_size defaults to 100.

Error handling

Every non-2xx response raises a typed exception carrying #status, #response_body, and #request_id.

begin
  client.contacts.get_all_tasks(contact_id: "missing")
rescue HighLevel::NotFoundError => e
  e.status        # => 404
  e.response_body # => parsed JSON body
  e.request_id    # => the x-request-id header, if present
end
Exception Trigger
HighLevel::BadRequestError 400
HighLevel::UnauthorizedError 401
HighLevel::ForbiddenError 403
HighLevel::NotFoundError 404
HighLevel::UnprocessableEntityError 422
HighLevel::RateLimitError 429
HighLevel::ServerError 5xx
HighLevel::Error other non-2xx
HighLevel::ConfigurationError bad/missing credentials
HighLevel::NetworkError transport-level failure

All inherit from HighLevel::Error, so rescue HighLevel::Error catches everything.

Instrumentation

The SDK emits no logs. Pass any object responding to #instrument(name, payload, &block)ActiveSupport::Notifications is the obvious one — as instrumenter: and subscribe to the request.high_level event.

client = HighLevel::Client.new(
  private_integration_token: "pit-xxxx",
  instrumenter: ActiveSupport::Notifications
)

ActiveSupport::Notifications.subscribe("request.high_level") do |*, payload|
  Rails.logger.info("HighLevel #{payload[:method].upcase} #{payload[:url]}#{payload[:status]}")
end

With no instrumenter configured, the instrumentation middleware is a transparent pass-through.

Differences from the official TypeScript SDK

  • Naming — methods are snake_case; arguments are keyword arguments.
  • No global state — every collaborator is injected through HighLevel::Configuration; there is no global mutable config. Thread-safe by construction.
  • Webhook verification raisesHighLevel::Webhooks.verify returns true or raises InvalidSignatureError, rather than returning a boolean.
  • Pagination is explicitHighLevel::Pagination is opt-in; resource methods never auto-paginate.
  • Storage gems are optionalredis/activerecord/mongo are lazy-required, not runtime dependencies.
  • Generated code is committedbundle add gohighlevel gives you a working library; there is no install-time codegen.

Contributing

See CONTRIBUTING.md for the spec-sync, regeneration, and drift-check workflows.

License

MIT — see LICENSE.txt.