Post-receive hooks fire after the push has been received and accepted. They are useful for providing a message to the client.

A typical usage of post-receive hooks is for passing information to a downstream process…​ for example:

You can combine these with conditions, for instance only posting on IM when there is a new commit on a release branch, or a new tag has been created.

If you don’t need to output a message to the client, it may be easier to listen for a RepositoryPushEvent.

Adding a Post-Receive Hook

Navigate to Admin → Script Post Hooks. Click a heading to add a handler. Choose Custom Post-Hook to use your own scripts to respond to pushes.

Built-in Post Hooks

Send Custom Email Following Code Push

With this built-in post-receive hook, your Bitbucket instance will send email notifications automatically after a post-receive hook has been triggered.

In order to configure it, a list of repositories for which this trigger will be in effect must be provided, as well as some other information related to the email that will be sent: subject and email templates, email format (plain text or HTML), a list of "to" email addresses and an optional list of "cc" email addresses.

This functionality will mostly require the use of a condition, to avoid sending emails every time something is pushed to a set of repositories.

The following is a basic configuration of the post hook to watch all commits in the repository "test" that involve a file with the .adoc extension:


With that configuration and after pushing some commits to the repository, you could get an output like the one shown in the following email:

mail post hook result html

There are some useful links to the Bitbucket instance which will only appear in the HTML version of the notification, more specifically you will have access to the Project, Repository, Commit and Change pages.

This functionality can be also configured to send plain text emails, in which case the output would look like the the one in the following screenshot:

mail post hook result plain
The content of the "to addresses" and "cc addresses" should be a list of emails separated by commas or spaces, being the following two examples correct: -, -

Customising Your Templates

Here are three extremely simple examples to illustrate how an email template can be customised making use of some helper methods which are available in the binding:

  • Show all ref changes:

    List of all refChanges:
    <% refChanges.each { refChange -> %>
    - Commits pushed to ${}
    <% } %>
  • Show all commits:

    List of all commmits:
    <% refChanges.getCommits(repository).each { commit -> %>
    - ${} | ${commit.displayId} | ${commit.message} | ${commit.authorTimestamp}
    <% } %>
  • Show all changesets and their details:

    List of all changesets and the paths to the files changed:
        refChanges.each { refChange ->
            refChange.getChangesets(repository).each { changeset ->
    - ${} | ${changeset.toCommit.displayId} | ${changeset.toCommit.message} | ${changeset.toCommit.authorTimestamp} | ${changeset.changes.values.size()} files changed
    <% changeset.changes.values.each { change -> %>
    ${change.path.toString()}, ${change.type.toString().toLowerCase()}
    <% }}} %>

Additional Configuration in Emails

You may notice the syntax for getting content in the template is a bit clunky, as the template engine does not allow you to use the import keyword. Rather than doing this, you can pass in a config map to the bindings for both the subject and body templates. This is done in the Mail configuration section.

A simple example of a Mail configuration section that defines config variables when changes are pushed to the repository is:

config.changes = ""

def refChanges = refChanges.collect { refChange ->
    config.changes += "- Commits pushed to ${}\n"

The body template:

List of all refChanges:


As a result of that configuration, an email will be sent to the selected email addresses with content similar to the following one:

List of all refChanges:
- Commits pushed to refs/heads/master

Respond to Pushes if Pull Request is Outdated or Conflicted

This is closely related to blocking out of date pull requests, which has more details of why you would want to smooth the workflow around pull requests.

This post-receive hook will fire if the developer is pushing any changes on a branch, for which a pull request exists with a corresponding from branch. It warns developers if their topic branch is lagging behind their target branch - showing how many commits behind they are, and the date of the first commit on the target branch which is not reachable from their topic branch.

The output will be similar to:

remote: View pull request for topic => master:
remote:   http://localhost:7990/bitbucket/projects/TEST/repos/test/pull-requests/2
remote: The branch 'topic' is 2 commit(s) behind master.
remote:   The first commit on the target that you're missing was at Fri Jun 05 14:12:53 BST 2015 (server's timezone).
remote:   Please consider rebasing.

This gives the developer an idea of how far they are lagging their target branch.

Additionally, should there be conflicts with the target branch, they will also see:

remote: In addition the following files have conflicts:
remote:   include/ap_compat.h
remote:   include/mod_request.h

The intention is to let developers know about merge conflicts as soon as possible, as the earlier you resolve them the easier it is, plus it keeps the feature branch "deliverable".

Respond to Pushes with a Message

A trivial script that responds to pushes that match the condition with a message. You could use it to warn about upcoming server maintenance, as users will often not open the Bitbucket user interface until they need to create a pull request:

broadcast message

On commit a user will see:

Counting objects: 5, done.
Writing objects: 100% (3/3), 264 bytes | 0 bytes/s, done.
Total 3 (delta 0), reused 0 (delta 0)
remote: =====================================================================
remote: Server maintenance this weekend 6AM-9AM EST!
remote: Bitbucket will be unavailable during this time,
remote: please commit locally and push when available.
remote: Sorry!
remote: =====================================================================

Another example is to remind users to do some administrative task after creating a release tag for example:

message on tag

On tag:

> git tag 1.1
> git push --tags
Total 0 (delta 0), reused 0 (delta 0)
remote: =====================================================================
remote: You've created a tag, please be sure to to
remote: update the JIRA version.
remote: =====================================================================
 * [new tag]         1.1 -> 1.1

Diagnosable Conditions

Most of the built-in post-receive hooks are applied based on an arbitrary condition. Diagnosable conditions allow you to show the user exactly why the hook was applied for their pushed changes.

Take a look at the examples in the diagnosable conditions section for pre-receive hooks where the diagnosable conditions are similar.

Custom Post-Receive Hooks

Asynchronous Post Hooks

By default, custom post-hooks execute synchronously. This means that repository pushes are delayed until the post-hook has completed execution.

If your custom post-hook does not require the push to have completed before execution, you should prefer executing asynchronously. This prevents a noticeable delay in pushes to end-users. Executing asynchronously also means that post-hooks can execute in parallel.

Synchronous post-hooks are not triggered by UI interactions, such as editing a file in the built-in file editor within Bitbucket.

Asynchronous execution is an opt-in because it changes some hook behaviours, such as the ability to write messages to the Git client.

To opt-in to asynchronous execution, check the Execute asynchronously checkbox:


Once asynchronous execution has been enabled, you are able to select custom triggers that only support asynchronous execution, such as file-edit.

Asynchronous post-hooks are not able to write messages to the Git client on push. If your use case requires writing a message to the Git client, you must use a synchronous post-hook.


The following examples will walk you through writing a custom post-receive hook.

The same variables as in pre-receive hooks are available in the binding.

Push Traceability

This relates to BSERV-2642. In this example we use a post-receive hook to store the user ID of the person that pushed commits in git notes. Note that the person pushing may not necessarily be the person that authored the commits.

If you want to validate commit authors and prevent pushing of commits that don’t follow your policy, see enforce trusted commit authors. This post-receive hook just records the credentials of the pushing user, for later verification.

You can view the notes by fetching them and using an argument to git log:

git fetch origin refs/notes/push-traceability:refs/notes/push-traceability
git log --show-notes=push-traceability
> commit 4c0b324d36b961d384afcba3a2f831d23486e194
> Author: Administrator <>
> Date:   Fri Sep 11 13:48:32 2015 +0100
>     Added testfile
> Notes (push-traceability):
>     {
>         "date": "2015-09-11T02:03:17+0100",
>         "userId": 1267,
>         "userName": "jechlin"
>     }

Read more about git notes in the documentation and a helpful blog post.

The script is a Custom Post-Hook post-receive hook:

import com.atlassian.plugin.PluginAccessor
import com.atlassian.sal.api.component.ComponentLocator
import com.atlassian.bitbucket.repository.RefChange
import com.atlassian.bitbucket.repository.Repository
import com.atlassian.bitbucket.scm.git.command.GitCommandBuilderFactory
import com.atlassian.bitbucket.auth.AuthenticationContext
import com.onresolve.scriptrunner.canned.bitbucket.bulkedit.StringCommandOutputHandler
import com.onresolve.scriptrunner.runner.ScriptRunnerImpl
import groovy.json.JsonBuilder
import org.apache.commons.lang.SystemUtils

def repository = repository as Repository
def refChanges = refChanges as Collection<RefChange>

final String NAMESPACE = "push-traceability"

def AuthenticationContext = ComponentLocator.getComponent(AuthenticationContext)

def pluginAccessor = ComponentLocator.getComponent(PluginAccessor)
def gitCommandBuilderFactory = ScriptRunnerImpl.getOsgiService(GitCommandBuilderFactory)

def currentUser = AuthenticationContext.getCurrentUser()

def pushDetails = [

    date    : new Date().format("yyyy-MM-dd'T'hh:mm:ssZ"),
    userId  :,

def pushDetailsJson = new JsonBuilder(pushDetails).toPrettyString()
if (SystemUtils.IS_OS_WINDOWS) {
    pushDetailsJson = pushDetailsJson.replaceAll("\"", /\\"/)

refChanges.getCommits(repository).each { commit ->
        .build(new StringCommandOutputHandler()).call()

In order to prevent anyone updating the notes in this namespace, you can add a protect refs pre-receive hook, where the condition is:

refChanges.any { == "refs/notes/push-traceability"}

It’s possible to add the push details to the commits page in the Bitbucket UI. We’ll add this if there is interest.

Update Static Site

This is an example of deploying static content such as a web site to a web server. Either the head of master could be deployed, or you can make use of a floating release label, called for instance PRODUCTION. When we detect the label has moved we deploy the content to the web server.

Typically you would copy using scp or rsync, which requires password-less SSH access to the remote web server. In this example we’ll just assume that the web server is serving content from a local directory for simplicity.

You could easily extend this to support additional labels, such as STAGING. The staging tag would be at or head of the production tag, and would deploy to a staging server for QA or user acceptance testing.

If you are deploying to a remote web server you can avoid all of this, and just replace with a command such as:

git archive --format zip PRODUCTION | \
    ssh -c " | tar -x -C /path/to/doc/root"

This script looks more complex than it really is. All it does is:

  • If the target directory doesn’t exist, create it

  • If the target directory is empty, clone the repository

  • Update to the PRODUCTION tag

import com.atlassian.bitbucket.hook.HookResponse
import com.atlassian.bitbucket.repository.RefChange
import com.atlassian.bitbucket.repository.RefChangeType
import com.atlassian.bitbucket.repository.Repository
import com.onresolve.scriptrunner.canned.bitbucket.util.BitbucketBaseScript
import com.onresolve.scriptrunner.canned.bitbucket.util.BitbucketCannedScriptUtils
import groovy.transform.BaseScript

@BaseScript BitbucketBaseScript baseScript (1)

Repository repository = repository
Collection<RefChange> refChanges = refChanges
HookResponse hookResponse = hookResponse

final def productionTag = "PRODUCTION"
final def webDirLocation = System.getProperty("") + "/web/" (2)

if (!refChanges.any {"refs/tags/$productionTag") &&
        it.type in [RefChangeType.ADD, RefChangeType.UPDATE]
}) {
    log.debug("No change to tag, exiting")

def target = new File(webDirLocation)
def handler = new SingleLineOutputHandler()

if (!target.exists()) {

if (!target.list()) { (3)
} else {
    gitCommandBuilderFactory.builder(repository) (4)

gitCommandBuilderFactory.builder() (5)

def msg = new StringBuilder("I have updated the website at $webDirLocation for you") (6)
1 Extend a base script, which gives us access to gitCommandBuilderFactory
2 Path to web site directory, that should be updated. I am using tmp just for test purposes. This should be an empty directory, or a path that will be created.
3 Target directory doesn’t exist, so create a clone of this repository there
4 Target directory exists, so force push all refs to it
5 Checkout web dir clone to tag
6 Inform the user so they can do a quick sanity check

Using the New Hooks API

There are a number of reasons why you may want to write your custom hook using the new Hooks API approach, which are highlighted below.

Hook Triggers

UI changes

Changes aren’t always pushed in Bitbucket Server, you can for example create branches and tags through the UI. The old approach was to have a hook to handle the pushes and an event handler to handle the UI action. This resulted in duplication of business logic.

For example if you wanted a hook that checks branch names you could apply the following triggers to it:

  • branch-create

  • repo-push

The full list of triggers are explained in more detail here.
Checking Triggers

You can respond to different triggers in different ways by checking the trigger in the hook request.

The example below checks for tag create or branch create triggers:

import com.atlassian.bitbucket.hook.repository.StandardRepositoryHookTrigger

if (hookRequest.trigger == StandardRepositoryHookTrigger.BRANCH_CREATE) {
    // handle branch create
} else if (hookRequest.trigger == StandardRepositoryHookTrigger.TAG_CREATE) {
    // handle tag create

Commit Details

Previously to get a list of commits that we’re being pushed you could use refChanges.getCommits() or use the CommitService directly.

The new Hooks API helps you to get commits by providing them to you one by one. This forces you to write your hook in a more performant way when you have pushes containing a large number of commits.

This is best illustrated in the following example which shows a hook that logs the ids of commits that were added or removed. The part we’re interested in is the commitCallback section which provides us with the commits and gets the ids.

import com.atlassian.bitbucket.hook.repository.CommitAddedDetails
import com.atlassian.bitbucket.hook.repository.CommitRemovedDetails
import com.atlassian.bitbucket.hook.repository.PreRepositoryHookCommitCallback
import com.atlassian.bitbucket.hook.repository.RepositoryHookResult

import javax.annotation.Nonnull

import static com.atlassian.bitbucket.hook.repository.RepositoryHookCommitFilter.ADDED_TO_ANY_REF
import static com.atlassian.bitbucket.hook.repository.RepositoryHookCommitFilter.REMOVED_FROM_ANY_REF

commitCallback = new PreRepositoryHookCommitCallback() { (1)

    boolean onCommitAdded(@Nonnull CommitAddedDetails commitDetails) {
        def commit = commitDetails.commit (2)"Commit added - $commit.displayId") (3)
        return true (4)

    boolean onCommitRemoved(@Nonnull CommitRemovedDetails commitDetails) {
        def commit = commitDetails.commit"Commit removed - $commit.displayId")
        return true

    RepositoryHookResult getResult() {
        RepositoryHookResult.accepted() (5)

1 Create a commit callback which will give us the commits that have been pushed.
2 Get a single commit from the CommitDetails when a commit is added.
3 Log the commit id.
4 Return true to move onto the next commit.
5 Always accept the push in a post-hook.
6 We want to check commit messages whenever a commit has been added or removed so we add ADDED_TO_ANY_REF adn REMOVED_FROM_ANY_REF to the commitFilters binding.

The commitCallback is simply created in step 1, it is only run after step 6.

Another confusing concept is the commitFilter binding. This just indicates what type of commits do I want to check. The full list of filters can be found here.

It’s recommended you use this pattern whenever you need to check commits in a hook.

Further information on getting commit details are explained here.

Have questions? Visit the Atlassian Community to connect, share, and learn with other Atlassian users and experts, including Adaptavist staff.

Ask a question about ScriptRunner for JIRA, Bitbucket Server, or Confluence.

Want to learn more? Check out courses on Adaptavist Learn, an online platform to onboard and train new users for Atlassian solutions.