ConnectGH DMS Tutorials

IT teams and integrators

ConnectGH DMS API Integration Guide

Version 1.5 · 12 October 2026

1. About This Guide

This guide is for the IT team or supplier connecting another system to ConnectGH DMS: an ERP that files invoices, a core banking system that stores loan files, an HR system, or a scanning station. It explains how to get an API key, how to call the API, and every call a key can make.

1.1 How it works

1.2 Conventions

2. Getting a Key

Ask the organisation's administrator for a key, telling them:

The administrator sees the full key once and passes it to you. It looks like cgh_k7f3a2b9_ followed by 40 more characters. The first part (cgh_k7f3a2b9) identifies the key on their API Keys page.

Note Store the key like a password: in your system's secret store or a password manager, never in source code, a web page, a phone app, email or chat. Use one key per system, so each can be limited and revoked on its own.

Keys expire after 90 days, 6 months or 1 year, as the administrator chose. They are emailed 14 days before; they then rotate the key (a new secret, and the old one stops working at once) or extend it, and give you the new key.

3. Making Calls

Send the key in the Authorization header of every call:

curl -H "Authorization: Bearer $CGH_API_KEY" https://dms.connectgh.org/api/folders

3.1 Responses and errors

Successful calls answer 200 OK, or 201 Created when something was made. Errors answer with a status code and a JSON body with a plain-language message:

{"error": "This API key can't be used from this address."}
Status Meaning What to do
400 Something in the request is missing or invalid Read the message and fix the request
401 The key is wrong, revoked or expired Ask the administrator for a new key
403 Not allowed: API Access is off, a call from an address not on the key's list, a route keys can't use, an action the key wasn't given, or a folder it can't reach Read the message; ask the administrator to change the key if needed
404 Not found, or not visible to this key Check the id; the key may not reach that folder
409 A conflict, for example an upload that hasn't finished Read the message
413 A file is too large, or the organisation's storage is full Upload in parts (5.5), or ask the administrator
422 A file was refused by the content check or virus scan Don't retry the same file
429 Too many calls this minute Wait for the Retry-After seconds, then continue
5xx A problem on the server Retry later, waiting longer each time

3.2 Rate limit

Every response carries X-RateLimit-Limit (calls allowed a minute) and X-RateLimit-Remaining (calls left this minute). Over the limit, calls answer 429 with a Retry-After header giving the seconds to wait.

3.3 Unchanged answers

Answers to GET calls carry an ETag header. Send it back in If-None-Match the next time you ask for the same thing: if nothing has changed, the answer is 304 Not Modified with no body, so a system that checks a folder regularly doesn't download the same list again. The call still counts toward the rate limit.

4. What a Key Can Call

A key may call only the routes below, and only those its access opens. Everything else answers 403 with "This route isn't available to API keys."

Route Read only Upload only Read & write
GET /folders, GET /folders/{id} Yes Yes Yes
GET /documents, GET /documents/{id}, download a version, GET /tags Yes No Yes
Upload: GET /uploads/config, /uploads calls, POST /documents No Yes Yes
PATCH /documents/{id}, POST /documents/{id}/move, POST /documents/{id}/versions No No Yes
POST /folders No With "Create subfolders" With "Create subfolders"
GET /workflow-templates, GET and POST /documents/{id}/workflow-runs No No With "Start workflows"
GET /intake-forms, GET /intake-forms/{id}/submissions With "Read form submissions" No With "Read form submissions"

Within those routes the key reaches only its folders and what's inside them. The organisation's own modules must be on, too: workflows need Document Workflows, forms need Intake Forms.

5. Folders and Documents

5.1 Finding your folders

curl -H "Authorization: Bearer $CGH_API_KEY" "https://dms.connectgh.org/api/folders"

Returns {"folders": [...]}: the top-level folders the key can reach, A to Z. Each folder has id, name, parentId, path (for example "Finance / Invoices"), documentCount and subfolderCount. Add ?parentId=12 to list the folders inside folder 12.

GET /folders/{id} returns {"folder", "breadcrumb", "subfolders", "documents"} for one folder: its details, its path, its subfolders, and every document directly in it. Add ?documents=0 to leave the documents out when you only need the folder's details and subfolders; for a large folder, list its documents a page at a time instead (5.2).

Note If the key was given a folder that sits inside another folder, it won't appear in the top-level list. Ask the administrator for the folder ids: their API Keys page shows them with each key, for example "Invoices (id 12)", and the example given with a new key opens its first folder. Then call GET /folders/12.

5.2 Listing and searching documents

curl -H "Authorization: Bearer $CGH_API_KEY" "https://dms.connectgh.org/api/documents?folderId=12&page=1"

Returns {"documents": [...], "page", "pages", "total"}, newest changes first, 20 a page.

Parameter Meaning
folderId Documents directly in this folder (not its subfolders)
page Page number, from 1
q Search words, matched against the title, tags, description and the text inside the files (including scanned pages). Returns up to 50 best matches on one page, across every folder the key reaches; folderId is ignored

Each document has id, title, description, folderId, folderPath, tags, ownerUsername, createdAt, updatedAt, and its latest file: latestVersionId, latestVersionNumber, latestOriginalFilename, latestFileSize (bytes) and latestMimeType.

GET /documents/{id} returns {"document": {...}} with the same fields plus versions (the latest version: id, versionNumber, originalFilename, fileSize, mimeType, uploadedBy, uploadedAt, changeNote) and attachments.

5.3 Downloading a file

Use the document's latestVersionId:

curl -H "Authorization: Bearer $CGH_API_KEY" -OJ \
  "https://dms.connectgh.org/api/documents/345/versions/678/download"

The answer is the file itself, with its original name in Content-Disposition. Keys download the latest version of each document; earlier versions are for the organisation's administrators. If the organisation watermarks downloads, PDFs and images arrive watermarked.

5.4 Uploading a document (up to 100 MB)

Send the file as a multipart form, with the folder to put it in:

curl -H "Authorization: Bearer $CGH_API_KEY" \
  -F "folderId=12" -F "title=Invoice 2026-0412" -F "files=@invoice-0412.pdf" \
  https://dms.connectgh.org/api/documents
Field Meaning
folderId Required: one of the key's folders, or a folder inside them
files The file. Repeat the field for several files; each becomes its own document unless mode=combined, where the first is the document and the rest its attachments
title Optional, when uploading one file; otherwise each document is named after its file
description Optional

Answers 201 with {"id", "ids", "document", "documents"}: the new document's id (and all ids, for several files). Each file is checked against its type and virus-scanned before it is stored; a refused file answers 422. Allowed types: pdf, docx, doc, dotx, xlsx, xls, csv, pptx, ppt, txt, md, png, jpg, jpeg, tiff, bmp, gif, dxf, dwg, dwt, stl, obj, gltf, glb, ply and fbx.

Note Don't send tags - keys can't create or apply tags, and the upload is refused. There's no duplicate check: if an upload call times out, list the folder before trying again, so the document isn't stored twice.

5.5 Uploading large files in parts

For files over 100 MB, or on unreliable links, upload in parts and resume after a break. The limits are in GET /uploads/config: chunkBytes (the part size to use, 4 MB), maxUploadBytes (largest file, 2 GB unless the organisation's server is set otherwise) and allowedExtensions.

  1. Start: POST /uploads with {"filename": "statement.pdf", "size": 52428800}. The answer has the upload's id and chunkBytes.
  2. Send each part in order: PUT /uploads/{id} with the raw bytes as the body and the headers Upload-Offset: <bytes sent so far> and Content-Type: application/octet-stream. Each answer has offset (bytes received) and complete.
  3. If an answer is 409 "Offset mismatch", it includes the offset the server has; continue from there. After a dropped connection, GET /uploads/{id} gives the offset to resume from.
  4. When an answer has "complete": true, the file has been checked. If blocked isn't empty, the file was refused.
  5. File it: POST /documents with {"folderId": 12, "uploadIds": ["<id>"], "title": "Statement"}. The answer is as in 5.4.

Unfinished uploads expire after 24 hours. DELETE /uploads/{id} cancels one.

5.6 Adding a new version (Read & write)

POST /documents/{id}/versions with either a multipart file field, or JSON {"uploadId": "<id>"} after uploading in parts, and an optional changeNote. Answers 201 with the updated document; latestVersionNumber is the new version.

5.7 Changing details and moving (Read & write)

5.8 Creating a subfolder ("Create subfolders")

POST /folders with {"name": "2026", "parentId": 12}. parentId is required: keys create folders inside their own folders. Answers 201 with {"folder": {...}}. A folder with the same name in the same place answers 400.

6. Workflows and Forms

6.1 Starting a workflow ("Start workflows")

  1. GET /workflow-templates lists the organisation's active workflows: {"templates": [{"id", "name", "description", "stages": [...]}]}.
  2. POST /documents/{id}/workflow-runs with {"templateId": 3, "note": "From the ERP"} starts one on a document. Answers 201 with {"run": {...}}.
  3. GET /documents/{id}/workflow-runs returns {"runs": [...]}, newest first. A run's status is in_progress, completed, rejected or cancelled; its steps show each step's name, status, actedBy and actedAt.

A document can have one workflow in progress at a time. Check the run's status every few minutes rather than every second.

6.2 Reading form submissions ("Read form submissions")

  1. GET /intake-forms lists the organisation's forms: id, name, isActive, submissionCount, newCount, and fields (each with label, fieldType and key).
  2. GET /intake-forms/{id}/submissions?status=new returns {"submissions": [...]}, newest first, up to 1,000. Each has id, reference, status, submittedAt, submitterName, submitterEmail, documentId (the filed PDF) and answers (the answers, by field key). status can be new, in_review, in_progress, approved, declined or closed. A signature question's answer has a type: drawn, typed, or thumbprint (with thumb and source: camera, inked or reader); the thumbprint image itself isn't in the answers - it's on the filed PDF.

7. Worked Example: Filing Invoices from an ERP

A Python script, using the requests library, that uploads every PDF in a folder into ConnectGH DMS folder 12 in parts, waits when the rate limit is reached, and remembers what it has sent:

import json, os, pathlib, time
import requests

BASE = "https://dms.connectgh.org/api"
HEAD = {"Authorization": f"Bearer {os.environ['CGH_API_KEY']}"}
FOLDER_ID = 12
SENT = pathlib.Path("sent.json")

def call(method, path, **kw):
    while True:
        r = requests.request(method, BASE + path, headers={**HEAD, **kw.pop("headers", {})},
                             timeout=300, **kw)
        if r.status_code == 429:
            time.sleep(int(r.headers.get("Retry-After", "5")))
            continue
        return r

def upload(path, title):
    size = path.stat().st_size
    up = call("POST", "/uploads", json={"filename": path.name, "size": size})
    up.raise_for_status()
    up = up.json()
    offset = 0
    with path.open("rb") as f:
        while offset < size:
            f.seek(offset)
            r = call("PUT", f"/uploads/{up['id']}", data=f.read(up["chunkBytes"]),
                     headers={"Upload-Offset": str(offset),
                              "Content-Type": "application/octet-stream"})
            if r.status_code == 409:
                offset = r.json()["offset"]
                continue
            r.raise_for_status()
            state = r.json()
            offset = state["offset"]
    if state.get("blocked"):
        raise RuntimeError(f"{path.name} refused: {state['blocked']}")
    r = call("POST", "/documents",
             json={"folderId": FOLDER_ID, "uploadIds": [up["id"]], "title": title})
    r.raise_for_status()
    return r.json()["id"]

sent = json.loads(SENT.read_text()) if SENT.exists() else {}
for pdf in sorted(pathlib.Path("outbox").glob("*.pdf")):
    if pdf.name not in sent:
        sent[pdf.name] = upload(pdf, pdf.stem)
        SENT.write_text(json.dumps(sent, indent=1))
        print("filed", pdf.name, "as document", sent[pdf.name])

8. Good Practice

9. Getting Help

The organisation's administrator can see each key's status and its last 200 calls (with the result of each) under Admin tools → API Keys → Activity; start there when something fails.

For help with the API, contact ConnectGH support: info@connectgh.org · Tel +233 (0) 35 229 3915 · Mobile +233 (0) 244 086 889 · WhatsApp +233 (0) 209 857 782. Give the key's first part (for example cgh_k7f3a2b9), the time of the call and the error message, never the whole key.

Back to top ↑